Based on production experience from the React Native community and Expo team
Welcome to your comprehensive guide for building performant, maintainable React Native applications! This document is designed specifically for developers who are new to React Native or want to understand the “why” behind best practices, not just the “what.”
Table of Contents
- Understanding React Native Performance
- Core Rendering — CRITICAL
- List Performance — HIGH
- Animation — HIGH
- Scroll Performance — HIGH
- Navigation — HIGH
- User Interface — MEDIUM-HIGH
- React State — MEDIUM
- State Architecture — MEDIUM
- React Compiler — MEDIUM
- Design System — MEDIUM
- Monorepo — LOW
- JavaScript & Configuration — LOW
Understanding React Native Performance
Before diving into specific practices, let’s understand why performance matters in mobile apps and how React Native differs from web development:
Why Mobile Performance Matters
- User Expectations: Mobile users expect 60fps animations and instant responses
- Battery Life: Inefficient apps drain battery faster, leading to negative reviews
- App Store Ratings: Slow, janky apps get poor ratings and fewer downloads
- Device Diversity: You need to support everything from budget phones to flagships
- Memory Constraints: Mobile devices have limited RAM compared to desktops
Key Performance Metrics
- Time to Interactive (TTI): When can users actually tap buttons and scroll?
- Frame Rate: Are animations running at 60fps (16.67ms per frame)?
- Memory Usage: Is your app using reasonable amounts of RAM?
- Bundle Size: How long does the app take to download from the store?
Impact Levels Explained
- CRITICAL: Can crash your app or make it unusable (runtime crashes, major performance issues)
- HIGH: Significant impact on user experience (smooth scrolling, fast animations)
- MEDIUM: Noticeable improvements, especially in complex apps
- LOW: Nice-to-have optimizations and organizational patterns
Core Rendering
Impact: CRITICAL
Fundamental React Native rendering rules. Violations here cause runtime crashes or broken UI. These are the most important rules to follow.
1.1 Never Use && with Potentially Falsy Values
The Problem: In React Native, using && with falsy values like 0 or "" doesn’t just fail to render—it crashes your app in production.
Unlike React for web, React Native cannot render strings or numbers directly inside <View> components. When you write {count && <Text>{count}</Text>} and count is 0, React Native tries to render 0 as a child of View, which causes a hard crash.
❌ Crashes if count is 0 or name is "":
function Profile({ name, count }: { name: string; count: number }) {
return (
<View>
{name && <Text>{name}</Text>}
{count && <Text>{count} items</Text>}
</View>
);
}
// If name="" or count=0, renders the falsy value → crash
✅ Ternary with null:
function Profile({ name, count }: { name: string; count: number }) {
return (
<View>
{name ? <Text>{name}</Text> : null}
{count ? <Text>{count} items</Text> : null}
</View>
);
}
✅ Explicit boolean coercion:
function Profile({ name, count }: { name: string; count: number }) {
return (
<View>
{!!name && <Text>{name}</Text>}
{!!count && <Text>{count} items</Text>}
</View>
);
}
Best: Early return:
function Profile({ name, count }: { name: string; count: number }) {
if (!name) return null;
return (
<View>
<Text>{name}</Text>
{count > 0 ? <Text>{count} items</Text> : null}
</View>
);
}
Why This Matters:
- React Native strictly enforces that only
<Text>components can render strings - Web React is more lenient—this is a mobile-specific gotcha
- Always validate your conditionals in development with edge cases (0, "", null, undefined)
Lint Rule: Enable react/jsx-no-leaked-render from eslint-plugin-react to catch this automatically.
1.2 Wrap Strings in Text Components
The Problem: React Native will crash if you render a string outside of a <Text> component.
❌ Crashes:
import { View } from "react-native";
function Greeting({ name }: { name: string }) {
return <View>Hello, {name}!</View>;
}
// Error: Text strings must be rendered within a <Text> component.
✅ Correct:
import { View, Text } from "react-native";
function Greeting({ name }: { name: string }) {
return (
<View>
<Text>Hello, {name}!</Text>
</View>
);
}
Why This Matters:
- This is a fundamental difference from web development
<View>is analogous to a<div>, but cannot contain text nodes- Get in the habit of always using
<Text>for any visible text
List Performance
Impact: HIGH
Lists are the heart of most mobile apps (feeds, chat, settings). Poor list performance is the #1 cause of janky scrolling and app unresponsiveness.
2.1 Use a List Virtualizer for Any List
The Problem: When you map over an array and render items in a ScrollView, React Native mounts every single item upfront—even items the user can’t see yet.
Imagine a social media feed with 1,000 posts. With ScrollView, all 1,000 post components are created immediately, using memory and CPU for content that’s not even visible. This causes:
- Slow initial load times
- High memory usage (potentially crashing the app)
- Janky scrolling as the JavaScript thread struggles
❌ ScrollView renders all items at once:
function Feed({ items }: { items: Item[] }) {
return (
<ScrollView>
{items.map((item) => (
<ItemCard key={item.id} item={item} />
))}
</ScrollView>
);
}
// 50 items = 50 components mounted, even if only 10 visible
✅ Virtualizer renders only visible items:
import { LegendList } from "@legendapp/list";
function Feed({ items }: { items: Item[] }) {
return (
<LegendList
data={items}
renderItem={({ item }) => <ItemCard item={item} />}
keyExtractor={(item) => item.id}
estimatedItemSize={80}
/>
);
}
// Only ~10-15 visible items mounted at a time
Alternative (FlashList):
import { FlashList } from "@shopify/flash-list";
function Feed({ items }: { items: Item[] }) {
return (
<FlashList
data={items}
renderItem={({ item }) => <ItemCard item={item} />}
keyExtractor={(item) => item.id}
/>
);
}
How Virtualization Works:
Think of virtualization like a window looking at a long hallway. Instead of painting the entire hallway, you only paint what you can see through the window. As you move the window (scroll), you paint new sections and erase old ones.
- Only visible items (~10-15) are rendered at any time
- As you scroll, items are recycled (reused) instead of destroyed and recreated
- This keeps memory usage flat regardless of list size
When to Use:
- Any scrollable list with more than ~20 items
- Chat messages, social feeds, contact lists, settings menus
- Even short lists benefit (habit: default to virtualization)
2.2 Avoid Inline Objects in renderItem
The Problem: Creating new objects inside renderItem breaks React’s memoization, causing unnecessary re-renders of list items.
React uses reference equality to determine if props changed. When you create a new object inline ({ backgroundColor: 'red' }), it’s a different object on every render, so React thinks the props changed—even when the values are identical.
❌ Inline object breaks memoization:
function UserList({ users }: { users: User[] }) {
return (
<LegendList
data={users}
renderItem={({ item }) => (
<UserRow
// Bad: new object on every render
user={{ id: item.id, name: item.name, avatar: item.avatar }}
/>
)}
/>
);
}
❌ Inline style object:
renderItem={({ item }) => (
<UserRow
name={item.name}
// Bad: new style object on every render
style={{ backgroundColor: item.isActive ? 'green' : 'gray' }}
/>
)}
✅ Pass item directly or primitives:
function UserList({ users }: { users: User[] }) {
return (
<LegendList
data={users}
renderItem={({ item }) => (
// Good: pass the item directly
<UserRow user={item} />
)}
/>
);
}
✅ Pass primitives, derive inside child:
renderItem={({ item }) => (
<UserRow
id={item.id}
name={item.name}
isActive={item.isActive}
/>
)}
const UserRow = memo(function UserRow({ id, name, isActive }: Props) {
// Good: derive style inside memoized component
const backgroundColor = isActive ? 'green' : 'gray'
return <View style={[styles.row, { backgroundColor }]}>{/* ... */}</View>
})
✅ Hoist static styles in module scope:
const activeStyle = { backgroundColor: 'green' }
const inactiveStyle = { backgroundColor: 'gray' }
renderItem={({ item }) => (
<UserRow
name={item.name}
// Good: stable references
style={item.isActive ? activeStyle : inactiveStyle}
/>
)}
Why This Matters:
- Lists re-render frequently during scrolling
- Inline objects force every item to re-render on any parent update
- With memoization, items only re-render when their actual data changes
- The difference between 60fps and stuttering scroll
Note: If you have the React Compiler enabled, it handles memoization automatically and these manual optimizations become less critical.
2.3 Hoist Callbacks to the Root of Lists
The Problem: Creating callbacks inside renderItem creates new function references on every render, breaking memoization.
❌ Creates a new callback on each render:
return (
<LegendList
renderItem={({ item }) => {
// bad: creates a new callback on each render
const onPress = () => handlePress(item.id);
return <Item key={item.id} item={item} onPress={onPress} />;
}}
/>
);
✅ A single function instance passed to each item:
const onPress = useCallback(
(id: string) => {
handlePress(id);
},
[handlePress],
);
return (
<LegendList
renderItem={({ item }) => (
<Item key={item.id} item={item} onPress={() => onPress(item.id)} />
)}
/>
);
Even Better - Pass ID Only:
const UserRow = memo(function UserRow({ id, name }: Props) {
const handlePress = useCallback(() => {
// Access item.id here instead of creating closure
router.push(`/user/${id}`);
}, [id]);
return (
<Pressable onPress={handlePress}>
<Text>{name}</Text>
</Pressable>
);
});
Why This Matters:
- Functions are objects in JavaScript—new reference = “changed” to React
- Memoized components see the new callback and re-render unnecessarily
- In long lists, this causes visible stutter during scroll
2.4 Keep List Items Lightweight
The Problem: Heavy list items (with queries, context access, or expensive computations) cause jank during scrolling.
Virtualized lists render many items during scroll. If each item is expensive to render, the main thread can’t keep up with 60fps.
❌ Heavy list item:
function ProductRow({ id }: { id: string }) {
// Bad: query inside list item
const { data: product } = useQuery(["product", id], () => fetchProduct(id));
// Bad: multiple context accesses
const theme = useContext(ThemeContext);
const user = useContext(UserContext);
const cart = useContext(CartContext);
// Bad: expensive computation
const recommendations = useMemo(
() => computeRecommendations(product),
[product],
);
return <View>{/* ... */}</View>;
}
✅ Lightweight list item:
function ProductRow({ name, price, imageUrl }: Props) {
// Good: receives only primitives, minimal hooks
return (
<View>
<Image source={{ uri: imageUrl }} />
<Text>{name}</Text>
<Text>{price}</Text>
</View>
);
}
Move data fetching to parent:
// Parent fetches all data once
function ProductList() {
const { data: products } = useQuery(["products"], fetchProducts);
return (
<LegendList
data={products}
renderItem={({ item }) => (
<ProductRow name={item.name} price={item.price} imageUrl={item.image} />
)}
/>
);
}
For shared values, use Zustand selectors instead of Context:
// Incorrect: Context causes re-render when any cart value changes
function ProductRow({ id, name }: Props) {
const { items } = useContext(CartContext);
const inCart = items.includes(id);
// ...
}
// Correct: Zustand selector only re-renders when this specific value changes
function ProductRow({ id, name }: Props) {
const inCart = useCartStore((s) => s.items.has(id));
// ...
}
Guidelines for list items:
- No queries or data fetching
- No expensive computations (move to parent or memoize at parent level)
- Prefer Zustand selectors over React Context
- Minimize useState/useEffect hooks
- Pass pre-computed values as props
The Goal: List items should be simple rendering functions that take props and return JSX.
2.5 Pass Primitives to List Items for Memoization
The Problem: Object props require deep comparison or break memoization entirely.
❌ Object prop requires deep comparison:
type User = { id: string; name: string; email: string; avatar: string }
const UserRow = memo(function UserRow({ user }: { user: User }) {
// memo() compares user by reference, not value
// If parent creates new user object, this re-renders even if data is same
return <Text>{user.name}</Text>
})
renderItem={({ item }) => <UserRow user={item} />}
✅ Primitive props enable shallow comparison:
const UserRow = memo(function UserRow({
id,
name,
email,
}: {
id: string
name: string
email: string
}) {
// memo() compares each primitive directly
// Re-renders only if id, name, or email actually changed
return <Text>{name}</Text>
})
renderItem={({ item }) => (
<UserRow id={item.id} name={item.name} email={item.email} />
)}
Pass only what you need:
// Incorrect: passing entire item when you only need name
<UserRow user={item} />
// Correct: pass only the fields the component uses
<UserRow name={item.name} avatarUrl={item.avatar} />
Why This Matters:
- Primitives compare by value (
1 === 1is true) - Objects compare by reference (
{} !== {}even if contents are identical) - Shallow comparison is fast and predictable
- Reduces re-renders significantly in large lists
2.6 Use Item Types for Heterogeneous Lists
The Problem: When a list has different layouts (messages, images, headers), recycling items of one type into another causes layout thrashing.
FlashList/LegendList recycle components for performance. Without type hints, a message component might be recycled into an image component, causing expensive layout recalculations.
❌ Single component with conditionals:
type Item = {
id: string;
text?: string;
imageUrl?: string;
isHeader?: boolean;
};
function ListItem({ item }: { item: Item }) {
if (item.isHeader) {
return <HeaderItem title={item.text} />;
}
if (item.imageUrl) {
return <ImageItem url={item.imageUrl} />;
}
return <MessageItem text={item.text} />;
}
function Feed({ items }: { items: Item[] }) {
return (
<LegendList
data={items}
renderItem={({ item }) => <ListItem item={item} />}
recycleItems
/>
);
}
✅ Typed items with separate components:
type HeaderItem = { id: string; type: "header"; title: string };
type MessageItem = { id: string; type: "message"; text: string };
type ImageItem = { id: string; type: "image"; url: string };
type FeedItem = HeaderItem | MessageItem | ImageItem;
function Feed({ items }: { items: FeedItem[] }) {
return (
<LegendList
data={items}
keyExtractor={(item) => item.id}
getItemType={(item) => item.type}
renderItem={({ item }) => {
switch (item.type) {
case "header":
return <SectionHeader title={item.title} />;
case "message":
return <MessageRow text={item.text} />;
case "image":
return <ImageRow url={item.url} />;
}
}}
recycleItems
/>
);
}
Why This Matters:
- Recycling efficiency: Items with the same type share a recycling pool
- No layout thrashing: A header never recycles into an image cell
- Type safety: TypeScript can narrow the item type in each branch
- Better size estimation: Use
getEstimatedItemSizewithitemTypefor accurate estimates per type
2.7 Use Compressed Images in Lists
Impact: HIGH
Always load compressed, appropriately-sized images in lists. Full-resolution images consume excessive memory and cause scroll jank.
❌ Full-resolution images:
function ProductItem({ product }: { product: Product }) {
return (
<View>
{/* 4000x3000 image loaded for a 100x100 thumbnail */}
<Image
source={{ uri: product.imageUrl }}
style={{ width: 100, height: 100 }}
/>
<Text>{product.name}</Text>
</View>
);
}
✅ Request appropriately-sized image:
function ProductItem({ product }: { product: Product }) {
// Request a 200x200 image (2x for retina)
const thumbnailUrl = `${product.imageUrl}?w=200&h=200&fit=cover`;
return (
<View>
<Image
source={{ uri: thumbnailUrl }}
style={{ width: 100, height: 100 }}
contentFit="cover"
/>
<Text>{product.name}</Text>
</View>
);
}
Best Practices:
- Use
expo-imageorSolitoImagefor built-in caching and optimization - Request images at 2x the display size for retina screens
- Use blurhash placeholders while loading
- Set appropriate
priority(high for above-fold, low for below)
Animation
Impact: HIGH
Smooth animations are the hallmark of a quality mobile app. Poor animation performance is immediately noticeable to users.
3.1 Animate Transform and Opacity Instead of Layout Properties
The Problem: Animating layout properties (width, height, top, left) triggers expensive layout recalculation on every frame.
React Native’s layout system works like this:
- JavaScript sets styles
- Yoga layout engine calculates positions
- Native layer renders
When you animate height, steps 1-3 happen on every frame—60 times per second. This is slow and causes dropped frames.
❌ Animates height, triggers layout every frame:
import Animated, {
useAnimatedStyle,
withTiming,
} from "react-native-reanimated";
function CollapsiblePanel({ expanded }: { expanded: boolean }) {
const animatedStyle = useAnimatedStyle(() => ({
height: withTiming(expanded ? 200 : 0), // triggers layout on every frame
overflow: "hidden",
}));
return <Animated.View style={animatedStyle}>{children}</Animated.View>;
}
✅ Animates scaleY, GPU-accelerated:
import Animated, {
useAnimatedStyle,
withTiming,
} from "react-native-reanimated";
function CollapsiblePanel({ expanded }: { expanded: boolean }) {
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scaleY: withTiming(expanded ? 1 : 0) }],
opacity: withTiming(expanded ? 1 : 0),
}));
return (
<Animated.View
style={[{ height: 200, transformOrigin: "top" }, animatedStyle]}
>
{children}
</Animated.View>
);
}
✅ Animates translateY for slide animations:
import Animated, {
useAnimatedStyle,
withTiming,
} from "react-native-reanimated";
function SlideIn({ visible }: { visible: boolean }) {
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: withTiming(visible ? 0 : 100) }],
opacity: withTiming(visible ? 1 : 0),
}));
return <Animated.View style={animatedStyle}>{children}</Animated.View>;
}
GPU-Accelerated Properties:
transform(translate, scale, rotate)opacity
Layout Properties (Avoid Animating):
width,height,top,left,right,bottommargin,padding
Why This Matters:
- Transform and opacity run entirely on the GPU
- No layout calculations needed
- Smooth 60fps animations even on slower devices
- Battery efficient
3.2 Prefer useDerivedValue Over useAnimatedReaction
The Problem: useAnimatedReaction is overkill for simple value derivations and requires manual dependency tracking.
❌ useAnimatedReaction for derivation:
import { useSharedValue, useAnimatedReaction } from "react-native-reanimated";
function MyComponent() {
const progress = useSharedValue(0);
const opacity = useSharedValue(1);
useAnimatedReaction(
() => progress.value,
(current) => {
opacity.value = 1 - current;
},
);
// ...
}
✅ useDerivedValue:
import { useSharedValue, useDerivedValue } from "react-native-reanimated";
function MyComponent() {
const progress = useSharedValue(0);
const opacity = useDerivedValue(() => 1 - progress.get());
// ...
}
When to Use Each:
- useDerivedValue: For computed values based on other shared values (declarative, automatic dependencies)
- useAnimatedReaction: For side effects that don’t produce a value (logging, haptics, calling runOnJS)
3.3 Use GestureDetector for Animated Press States
The Problem: Pressable’s onPressIn/onPressOut callbacks run on the JavaScript thread, creating lag between touch and visual feedback.
❌ Pressable with JS thread callbacks:
import { Pressable } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
} from "react-native-reanimated";
function AnimatedButton({ onPress }: { onPress: () => void }) {
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<Pressable
onPress={onPress}
onPressIn={() => (scale.value = withTiming(0.95))}
onPressOut={() => (scale.value = withTiming(1))}
>
<Animated.View style={animatedStyle}>
<Text>Press me</Text>
</Animated.View>
</Pressable>
);
}
✅ GestureDetector with UI thread worklets:
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
interpolate,
runOnJS,
} from "react-native-reanimated";
function AnimatedButton({ onPress }: { onPress: () => void }) {
// Store the press STATE (0 = not pressed, 1 = pressed)
const pressed = useSharedValue(0);
const tap = Gesture.Tap()
.onBegin(() => {
pressed.set(withTiming(1));
})
.onFinalize(() => {
pressed.set(withTiming(0));
})
.onEnd(() => {
runOnJS(onPress)();
});
// Derive visual values from the state
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ scale: interpolate(withTiming(pressed.get()), [0, 1], [1, 0.95]) },
],
}));
return (
<GestureDetector gesture={tap}>
<Animated.View style={animatedStyle}>
<Text>Press me</Text>
</Animated.View>
</GestureDetector>
);
}
Key Principles:
- Store the press state (0 or 1), then derive the scale via
interpolate - This keeps the shared value as ground truth
- Use
runOnJSto call JS functions from worklets - Use
.set()and.get()for React Compiler compatibility
Why This Matters:
- Gesture callbacks run on the UI thread (worklets)
- No JavaScript thread round-trip
- Instant visual feedback
- Smoother experience
Scroll Performance
Impact: HIGH
Scrolling is the most common user interaction in mobile apps. Poor scroll performance is immediately noticeable.
4.1 Never Track Scroll Position in useState
The Problem: Scroll events fire rapidly (up to 60 times per second). Storing scroll position in useState causes a re-render on every frame—massive performance hit.
❌ useState causes jank:
import { useState } from "react";
import {
ScrollView,
NativeSyntheticEvent,
NativeScrollEvent,
} from "react-native";
function Feed() {
const [scrollY, setScrollY] = useState(0);
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
setScrollY(e.nativeEvent.contentOffset.y); // re-renders on every frame
};
return <ScrollView onScroll={onScroll} scrollEventThrottle={16} />;
}
✅ Reanimated for animations:
import Animated, {
useSharedValue,
useAnimatedScrollHandler,
} from "react-native-reanimated";
function Feed() {
const scrollY = useSharedValue(0);
const onScroll = useAnimatedScrollHandler({
onScroll: (e) => {
scrollY.value = e.contentOffset.y; // runs on UI thread, no re-render
},
});
return <Animated.ScrollView onScroll={onScroll} scrollEventThrottle={16} />;
}
✅ Ref for non-reactive tracking:
import { useRef } from "react";
import {
ScrollView,
NativeSyntheticEvent,
NativeScrollEvent,
} from "react-native";
function Feed() {
const scrollY = useRef(0);
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
scrollY.current = e.nativeEvent.contentOffset.y; // no re-render
};
return <ScrollView onScroll={onScroll} scrollEventThrottle={16} />;
}
When to Use Each:
- Reanimated shared value: For scroll-driven animations (header collapse, parallax)
- useRef: For tracking scroll position without displaying it (infinite scroll trigger)
Navigation
Impact: HIGH
Navigation transitions are core to the mobile experience. Native navigators provide better performance and platform-consistent behavior.
5.1 Use Native Navigators for Navigation
The Problem: JavaScript-based navigators run animations and gestures on the JS thread, causing frame drops and non-native feel.
Native navigators use platform APIs:
- iOS: UINavigationController
- Android: Fragment transactions
For stacks: Use @react-navigation/native-stack or expo-router’s default stack.
For tabs: Use react-native-bottom-tabs or expo-router’s native tabs.
❌ JS stack navigator:
import { createStackNavigator } from "@react-navigation/stack";
const Stack = createStackNavigator();
function App() {
return (
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Details" component={DetailsScreen} />
</Stack.Navigator>
);
}
✅ Native stack with react-navigation:
import { createNativeStackNavigator } from "@react-navigation/native-stack";
const Stack = createNativeStackNavigator();
function App() {
return (
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Details" component={DetailsScreen} />
</Stack.Navigator>
);
}
✅ Expo-router uses native stack by default:
// app/_layout.tsx
import { Stack } from "expo-router";
export default function Layout() {
return <Stack />;
}
❌ JS bottom tabs:
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
const Tab = createBottomTabNavigator();
function App() {
return (
<Tab.Navigator>
<Tab.Screen name="Home" component={HomeScreen} />
<Tab.Screen name="Settings" component={SettingsScreen} />
</Tab.Navigator>
);
}
✅ Native bottom tabs with react-navigation:
import { createNativeBottomTabNavigator } from "@bottom-tabs/react-navigation";
const Tab = createNativeBottomTabNavigator();
function App() {
return (
<Tab.Navigator>
<Tab.Screen
name="Home"
component={HomeScreen}
options={{
tabBarIcon: () => ({ sfSymbol: "house" }),
}}
/>
<Tab.Screen
name="Settings"
component={SettingsScreen}
options={{
tabBarIcon: () => ({ sfSymbol: "gear" }),
}}
/>
</Tab.Navigator>
);
}
✅ Expo-router native tabs:
// app/(tabs)/_layout.tsx
import { NativeTabs } from "expo-router/unstable-native-tabs";
export default function TabLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings">
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
</NativeTabs.Trigger>
</NativeTabs>
);
}
Why Native Navigators:
- Performance: Native transitions and gestures run on the UI thread
- Platform behavior: Automatic iOS large titles, Android material design
- System integration: Scroll-to-top on tab tap, proper safe areas
- Accessibility: Platform accessibility features work automatically
Prefer Native Headers:
// ✅ Use native header options
<Stack.Screen
name="Profile"
component={ProfileScreen}
options={{
title: "Profile",
headerLargeTitleEnabled: true,
headerSearchBarOptions: {
placeholder: "Search",
},
}}
/>
User Interface
Impact: MEDIUM-HIGH
Native UI patterns provide better performance, accessibility, and platform consistency.
6.1 Use expo-image for Optimized Images
The Problem: React Native’s built-in Image component lacks modern features like efficient caching, blurhash placeholders, and memory management.
❌ React Native Image:
import { Image } from "react-native";
function Avatar({ url }: { url: string }) {
return <Image source={{ uri: url }} style={styles.avatar} />;
}
✅ expo-image:
import { Image } from "expo-image";
function Avatar({ url }: { url: string }) {
return <Image source={{ uri: url }} style={styles.avatar} />;
}
With blurhash placeholder:
<Image
source={{ uri: url }}
placeholder={{ blurhash: "LGF5]+Yk^6#M@-5c,1J5@[or[Q6." }}
contentFit="cover"
transition={200}
style={styles.image}
/>
With priority and caching:
<Image
source={{ uri: url }}
priority="high"
cachePolicy="memory-disk"
style={styles.hero}
/>
Key Props:
placeholder: Blurhash or thumbnail while loadingcontentFit:cover,contain,fill,scale-downtransition: Fade-in duration (ms)priority:low,normal,highcachePolicy:memory,disk,memory-disk,nonerecyclingKey: Unique key for list recycling
Why This Matters:
- Automatic memory management prevents crashes
- Blurhash placeholders improve perceived performance
- Smart caching reduces network requests
- Better performance in lists
6.2 Use Native Menus for Dropdowns and Context Menus
The Problem: Custom JS menus don’t match platform UI patterns and lack accessibility features.
❌ Custom JS menu:
import { useState } from "react";
import { View, Pressable, Text } from "react-native";
function MyMenu() {
const [open, setOpen] = useState(false);
return (
<View>
<Pressable onPress={() => setOpen(!open)}>
<Text>Open Menu</Text>
</Pressable>
{open && (
<View style={{ position: "absolute", top: 40 }}>
<Pressable onPress={() => console.log("edit")}>
<Text>Edit</Text>
</Pressable>
<Pressable onPress={() => console.log("delete")}>
<Text>Delete</Text>
</Pressable>
</View>
)}
</View>
);
}
✅ Native menu with zeego:
import * as DropdownMenu from "zeego/dropdown-menu";
function MyMenu() {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Pressable>
<Text>Open Menu</Text>
</Pressable>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item key="edit" onSelect={() => console.log("edit")}>
<DropdownMenu.ItemTitle>Edit</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="delete"
destructive
onSelect={() => console.log("delete")}
>
<DropdownMenu.ItemTitle>Delete</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
}
Why Native Menus:
- Platform-consistent appearance (iOS context menus, Android popup menus)
- Built-in accessibility (screen readers, focus management)
- Proper positioning and safe area handling
- Haptic feedback on iOS
6.3 Use Native Modals Over JS-Based Bottom Sheets
The Problem: JS-based bottom sheets recreate native functionality in JavaScript, leading to performance issues and non-native feel.
❌ JS-based bottom sheet:
import BottomSheet from "custom-js-bottom-sheet";
function MyScreen() {
const sheetRef = useRef<BottomSheet>(null);
return (
<View style={{ flex: 1 }}>
<Button onPress={() => sheetRef.current?.expand()} title="Open" />
<BottomSheet ref={sheetRef} snapPoints={["50%", "90%"]}>
<View>
<Text>Sheet content</Text>
</View>
</BottomSheet>
</View>
);
}
✅ Native Modal with formSheet:
import { Modal, View, Text, Button } from "react-native";
function MyScreen() {
const [visible, setVisible] = useState(false);
return (
<View style={{ flex: 1 }}>
<Button onPress={() => setVisible(true)} title="Open" />
<Modal
visible={visible}
presentationStyle="formSheet"
animationType="slide"
onRequestClose={() => setVisible(false)}
>
<View>
<Text>Sheet content</Text>
</View>
</Modal>
</View>
);
}
✅ React Navigation v7 native form sheet:
<Stack.Screen
name="Details"
component={DetailsScreen}
options={{
presentation: "formSheet",
sheetAllowedDetents: "fitToContents",
}}
/>
Why Native Modals:
- Native gestures (swipe to dismiss)
- Proper keyboard avoidance
- Built-in accessibility
- Better performance
6.4 Modern React Native Styling Patterns
Use borderCurve: 'continuous' with borderRadius:
<View style={{ borderRadius: 16, borderCurve: "continuous" }} />
Use gap instead of margin for spacing between elements:
// Incorrect – margin on children
<View>
<Text style={{ marginBottom: 8 }}>Title</Text>
<Text style={{ marginBottom: 8 }}>Subtitle</Text>
</View>
// Correct – gap on parent
<View style={{ gap: 8 }}>
<Text>Title</Text>
<Text>Subtitle</Text>
</View>
Use CSS boxShadow string syntax for shadows:
// Incorrect – legacy shadow objects
{ shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1 }
// Correct – CSS box-shadow syntax
{ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' }
Use experimental_backgroundImage for gradients:
// Incorrect – third-party gradient library
<LinearGradient colors={['#000', '#fff']} />
// Correct – native CSS gradient syntax
<View
style={{
experimental_backgroundImage: 'linear-gradient(to bottom, #000, #fff)',
}}
/>
React State
Impact: MEDIUM
State management patterns that prevent unnecessary re-renders and stale closures.
7.1 Minimize State Variables and Derive Values
The Problem: Storing values that can be computed from other state creates synchronization issues and unnecessary re-renders.
❌ Redundant state:
function Cart({ items }: { items: Item[] }) {
const [total, setTotal] = useState(0);
const [itemCount, setItemCount] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, item) => sum + item.price, 0));
setItemCount(items.length);
}, [items]);
return (
<View>
<Text>{itemCount} items</Text>
<Text>Total: ${total}</Text>
</View>
);
}
✅ Derived values:
function Cart({ items }: { items: Item[] }) {
const total = items.reduce((sum, item) => sum + item.price, 0);
const itemCount = items.length;
return (
<View>
<Text>{itemCount} items</Text>
<Text>Total: ${total}</Text>
</View>
);
}
Why This Matters:
- Less state to manage and synchronize
- No useEffect needed
- Updates automatically when dependencies change
- Single source of truth
7.2 Use Functional setState for State That Depends on Current Value
The Problem: Reading state directly in callbacks can create stale closures.
❌ Requires state as dependency:
function TodoList() {
const [items, setItems] = useState(initialItems);
// This callback is recreated every time items change
const addItems = useCallback(
(newItems: Item[]) => {
setItems([...items, ...newItems]);
},
[items],
); // Causes callback recreation
return <ItemsEditor items={items} onAdd={addItems} />;
}
✅ Stable callbacks, no stale closures:
function TodoList() {
const [items, setItems] = useState(initialItems);
// Stable, never recreated
const addItems = useCallback((newItems: Item[]) => {
setItems((curr) => [...curr, ...newItems]);
}, []); // No dependencies needed
return <ItemsEditor items={items} onAdd={addItems} />;
}
Why This Works:
setItems(curr => ...)receives the latest state value- Callback doesn’t need items in its dependency array
- Stable reference prevents child re-renders
7.3 Use Lazy State Initialization
The Problem: Expensive initial state computations run on every render, not just on initialization.
❌ Runs on every render:
function FilteredList({ items }) {
const [searchIndex] = useState(buildSearchIndex(items)); // Runs every render!
const [query, setQuery] = useState("");
return <SearchResults index={searchIndex} query={query} />;
}
✅ Runs only once:
function FilteredList({ items }) {
const [searchIndex] = useState(() => buildSearchIndex(items)); // Only once
const [query, setQuery] = useState("");
return <SearchResults index={searchIndex} query={query} />;
}
When to Use:
- localStorage/sessionStorage reads
- Building indexes/maps
- Complex calculations
State Architecture
Impact: MEDIUM
Ground truth principles for state variables and derived values.
8.1 State Must Represent Ground Truth
The Problem: Storing visual values (scale, opacity) instead of semantic state (pressed, expanded) makes code harder to extend and debug.
❌ Storing the visual output:
const scale = useSharedValue(1);
const tap = Gesture.Tap()
.onBegin(() => {
scale.set(withTiming(0.95));
})
.onFinalize(() => {
scale.set(withTiming(1));
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.get() }],
}));
✅ Storing the state, deriving the visual:
const pressed = useSharedValue(0); // 0 = not pressed, 1 = pressed
const tap = Gesture.Tap()
.onBegin(() => {
pressed.set(withTiming(1));
})
.onFinalize(() => {
pressed.set(withTiming(0));
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.95]) }],
}));
Why This Matters:
- Single source of truth: State describes what’s happening; visuals are derived
- Easier to extend: Add opacity, rotation from the same state
- Debugging: Inspecting
pressed = 1is clearer thanscale = 0.95 - Reusable logic: Same state can drive multiple visual properties
Same Principle for React State:
// Incorrect: storing derived values
const [isExpanded, setIsExpanded] = useState(false);
const [height, setHeight] = useState(0);
useEffect(() => {
setHeight(isExpanded ? 200 : 0);
}, [isExpanded]);
// Correct: derive from state
const [isExpanded, setIsExpanded] = useState(false);
const height = isExpanded ? 200 : 0;
React Compiler
Impact: MEDIUM
Compatibility patterns for React Compiler with React Native and Reanimated.
9.1 Destructure Functions Early in Render (React Compiler)
The Problem: React Compiler works best with stable function references. Dotting into objects creates unstable references.
❌ Dotting into object:
import { useRouter } from "expo-router";
function SaveButton(props) {
const router = useRouter();
// Compiler keys cache on "props" and "router" objects
const handlePress = () => {
props.onSave();
router.push("/success");
};
return <Button onPress={handlePress}>Save</Button>;
}
✅ Destructure early:
import { useRouter } from "expo-router";
function SaveButton({ onSave }) {
const { push } = useRouter();
// Compiler keys on push and onSave
const handlePress = () => {
onSave();
push("/success");
};
return <Button onPress={handlePress}>Save</Button>;
}
9.2 Use .get() and .set() for Reanimated Shared Values
The Problem: With React Compiler enabled, direct .value access on shared values breaks memoization.
❌ Breaks with React Compiler:
import { useSharedValue } from "react-native-reanimated";
function Counter() {
const count = useSharedValue(0);
const increment = () => {
count.value = count.value + 1;
};
return <Button onPress={increment} title={`Count: ${count.value}`} />;
}
✅ React Compiler compatible:
import { useSharedValue } from "react-native-reanimated";
function Counter() {
const count = useSharedValue(0);
const increment = () => {
count.set(count.get() + 1);
};
return <Button onPress={increment} title={`Count: ${count.get()}`} />;
}
Design System
Impact: MEDIUM
Architecture patterns for building maintainable component libraries.
10.1 Use Compound Components Over Polymorphic Children
The Problem: Components that accept both strings and React nodes create ambiguous APIs.
❌ Polymorphic children:
type ButtonProps = {
children: string | React.ReactNode
icon?: React.ReactNode
}
function Button({ children, icon }: ButtonProps) {
return (
<Pressable>
{icon}
{typeof children === 'string' ? <Text>{children}</Text> : children}
</Pressable>
)
}
// Usage is ambiguous
<Button icon={<Icon />}>Save</Button>
<Button><CustomText>Save</CustomText></Button>
✅ Compound components:
function Button({ children }: { children: React.ReactNode }) {
return <Pressable>{children}</Pressable>;
}
function ButtonText({ children }: { children: React.ReactNode }) {
return <Text>{children}</Text>;
}
function ButtonIcon({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
// Usage is explicit and composable
<Button>
<ButtonIcon>
<SaveIcon />
</ButtonIcon>
<ButtonText>Save</ButtonText>
</Button>;
Why This Matters:
- Clearer component APIs
- TypeScript provides better autocomplete
- Easier to maintain and extend
- Consistent patterns across the codebase
Monorepo
Impact: LOW
Dependency management and native module configuration in monorepos.
11.1 Install Native Dependencies in App Directory
The Problem: In a monorepo, native dependencies installed only in shared packages won’t be linked.
Autolinking only scans the app’s node_modules—it won’t find native dependencies installed in other packages.
❌ Native dep in shared package only:
packages/
ui/
package.json # has react-native-reanimated
app/
package.json # missing react-native-reanimated
✅ Native dep in app directory:
packages/
ui/
package.json # has react-native-reanimated
app/
package.json # also has react-native-reanimated
// packages/app/package.json
{
"dependencies": {
"react-native-reanimated": "3.16.1"
}
}
11.2 Use Single Dependency Versions Across Monorepo
The Problem: Multiple versions of the same dependency cause bundle bloat and runtime conflicts.
❌ Version ranges, multiple versions:
// packages/app/package.json
{
"dependencies": {
"react-native-reanimated": "^3.0.0"
}
}
// packages/ui/package.json
{
"dependencies": {
"react-native-reanimated": "^3.5.0"
}
}
✅ Exact versions, single source of truth:
// package.json (root)
{
"pnpm": {
"overrides": {
"react-native-reanimated": "3.16.1"
}
}
}
JavaScript & Configuration
Impact: LOW
Micro-optimizations and configuration patterns.
12.1 Hoist Intl Formatter Creation
The Problem: Creating Intl.DateTimeFormat, Intl.NumberFormat objects in render is expensive.
❌ New formatter every render:
function Price({ amount }: { amount: number }) {
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
return <Text>{formatter.format(amount)}</Text>;
}
✅ Hoisted to module scope:
const currencyFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
function Price({ amount }: { amount: number }) {
return <Text>{currencyFormatter.format(amount)}</Text>;
}
12.2 Load Fonts Natively at Build Time
The Problem: Loading fonts at runtime with useFonts causes a flash of unstyled text.
❌ Async font loading:
import { useFonts } from "expo-font";
function App() {
const [fontsLoaded] = useFonts({
"Geist-Bold": require("./assets/fonts/Geist-Bold.otf"),
});
if (!fontsLoaded) {
return null;
}
return <View>{/* ... */}</View>;
}
✅ Config plugin, fonts embedded at build:
// No loading state needed—font is already available
function App() {
return (
<View>
<Text style={{ fontFamily: "Geist-Bold" }}>Hello</Text>
</View>
);
}
Configure in app.json:
{
"expo": {
"plugins": [
[
"expo-font",
{
"fonts": ["./assets/fonts/Geist-Bold.otf"]
}
]
]
}
}
Summary
This guide compiles production-proven best practices for React Native development. By following these patterns, you can:
Critical Improvements (Prevent Crashes)
- Never use && with falsy values — Prevents production crashes
- Wrap strings in Text components — Fundamental React Native requirement
- Install native deps in app directory — Required for autolinking in monorepos
High Impact Improvements (2-10× Performance)
- Use list virtualization — FlatList/LegendList/FlashList for any scrollable content
- Animate transform/opacity only — GPU-accelerated animations at 60fps
- Use native navigators — Native-stack and native-tabs for better UX
- Avoid useState for scroll position — Use shared values or refs instead
- Optimize list items — Primitives only, no inline objects, hoisted callbacks
Medium Impact Improvements (30-50% Better)
- Minimize state variables — Derive values instead of storing them
- Use native UI components — expo-image, zeego menus, native modals
- State represents ground truth — Derive visuals from semantic state
- Use compound components — Clearer APIs, better maintainability
Nice-to-Have (10-20% Better)
- Hoist formatters — Module-level Intl objects
- Single dependency versions — Consistent across monorepo
- Config plugin for fonts — Build-time font loading
Key Takeaways for Beginners
- Start with the critical stuff: Falsy && and Text components prevent crashes
- Master list performance: This is where most React Native apps struggle
- Think GPU for animations: Transform and opacity only
- Prefer native over JS: Native navigators, native menus, native modals
- State minimalism: Store less, derive more
- Measure on real devices: Simulators don’t show true performance
Learning Path
- First: Master conditional rendering and Text components (avoid crashes)
- Second: Learn list virtualization and optimization (smooth scrolling)
- Third: Understand GPU-accelerated animations (60fps)
- Fourth: Implement native navigation and UI patterns
- Finally: Dive into state architecture and advanced patterns
References
- React Native Documentation — Official React Native docs
- Expo Documentation — Expo framework and APIs
- Reanimated Documentation — Animation library
- Gesture Handler — Touch handling
- LegendList — High-performance list virtualization
- FlashList — Shopify’s list virtualization
- Expo Image — Optimized image component
- Zeego — Native menus for React Native
- React Navigation — Navigation library
- React Native Bottom Tabs — Native tabs
This guide is based on the react-native-skills from Vercel’s agent-skills repository. It’s designed to help developers understand not just what to do, but why these practices matter in real-world React Native applications.
Tags:
Related Posts
Design Systems 101: A Beginner's Guide to Building Consistency from Scratch
Learn how to build a design system from scratch and create consistent, professional interfaces. This beginner-friendly guide covers everything from color palettes and typography to components and documentation.
Complete TanStack Router Tutorial: Building a Todo App
Learn TanStack Router from scratch by building a complete Todo application. This comprehensive tutorial covers type-safe routing, data loading, mutations, navigation patterns, and error handling with practical examples.