Tutorials

React Native Best Practices: A Beginner's Guide

React Native Best Practices: A Beginner's Guide

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

  1. Understanding React Native Performance
  2. Core RenderingCRITICAL
  3. List PerformanceHIGH
  4. AnimationHIGH
  5. Scroll PerformanceHIGH
  6. NavigationHIGH
  7. User InterfaceMEDIUM-HIGH
  8. React StateMEDIUM
  9. State ArchitectureMEDIUM
  10. React CompilerMEDIUM
  11. Design SystemMEDIUM
  12. MonorepoLOW
  13. JavaScript & ConfigurationLOW

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 === 1 is 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 getEstimatedItemSize with itemType for 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-image or SolitoImage for 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:

  1. JavaScript sets styles
  2. Yoga layout engine calculates positions
  3. 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, bottom
  • margin, 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 runOnJS to 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)

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 loading
  • contentFit: cover, contain, fill, scale-down
  • transition: Fade-in duration (ms)
  • priority: low, normal, high
  • cachePolicy: memory, disk, memory-disk, none
  • recyclingKey: 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:

  1. Single source of truth: State describes what’s happening; visuals are derived
  2. Easier to extend: Add opacity, rotation from the same state
  3. Debugging: Inspecting pressed = 1 is clearer than scale = 0.95
  4. 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

  1. Start with the critical stuff: Falsy && and Text components prevent crashes
  2. Master list performance: This is where most React Native apps struggle
  3. Think GPU for animations: Transform and opacity only
  4. Prefer native over JS: Native navigators, native menus, native modals
  5. State minimalism: Store less, derive more
  6. Measure on real devices: Simulators don’t show true performance

Learning Path

  1. First: Master conditional rendering and Text components (avoid crashes)
  2. Second: Learn list virtualization and optimization (smooth scrolling)
  3. Third: Understand GPU-accelerated animations (60fps)
  4. Fourth: Implement native navigation and UI patterns
  5. Finally: Dive into state architecture and advanced patterns

References


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:

React Native Expo Mobile Development Performance Animation

Share this post: