React Native development has more moving parts than web development — native modules, platform differences between iOS and Android, Expo managed vs bare workflow, Metro bundler quirks, and Xcode/Android Studio build issues. Claude Code handles this complexity because it reads your project structure and understands the React Native ecosystem rather than treating it like a generic JavaScript project.
This guide covers React Native development with Claude Code: navigation, state management, native modules, performance, and testing.
Setting Up Claude Code for React Native
The Expo vs bare context matters a lot:
# React Native Project Context
## Stack
- React Native 0.74, Expo 51 (bare workflow — not managed)
- Navigation: React Navigation 6 (stack + bottom tabs)
- State: Zustand (not Redux)
- Data fetching: TanStack Query v5
- Styling: StyleSheet API (not NativeWind/Tailwind)
- Testing: Jest + React Native Testing Library
## Platform
- iOS 15+ and Android 13+ minimum
- Targeting phones only (not tablets)
- New Architecture enabled (Fabric + TurboModules)
## Conventions
- Platform-specific files: Component.ios.tsx / Component.android.tsx
- Expo modules for native features (camera, notifications, file system)
- All async operations use TanStack Query or Zustand slices
- No class components — hooks only
## Never
- Use deprecated APIs (ViewPropTypes, etc.)
- Inline styles for complex layouts — always StyleSheet.create()
- Install packages that require ejecting if we can use an Expo module
See the CLAUDE.md setup guide for complete configuration.
Navigation Patterns
Stack and Tab Navigation
Set up the app navigation:
- Bottom tabs: Home, Search, Profile
- Stack navigator inside Home (HomeList → HomeDetail)
- Auth flow: unauthenticated shows Login screen,
authenticated goes to tabs
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
type RootStackParamList = {
Auth: undefined;
Main: undefined;
};
type HomeStackParamList = {
HomeList: undefined;
HomeDetail: { id: string; title: string };
};
type TabParamList = {
Home: undefined;
Search: undefined;
Profile: undefined;
};
const RootStack = createNativeStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<TabParamList>();
const HomeStack = createNativeStackNavigator<HomeStackParamList>();
function HomeNavigator() {
return (
<HomeStack.Navigator>
<HomeStack.Screen name="HomeList" component={HomeListScreen} />
<HomeStack.Screen
name="HomeDetail"
component={HomeDetailScreen}
options={({ route }) => ({ title: route.params.title })}
/>
</HomeStack.Navigator>
);
}
function MainTabs() {
return (
<Tab.Navigator>
<Tab.Screen name="Home" component={HomeNavigator} />
<Tab.Screen name="Search" component={SearchScreen} />
<Tab.Screen name="Profile" component={ProfileScreen} />
</Tab.Navigator>
);
}
export function AppNavigator() {
const isAuthenticated = useAuthStore(state => state.isAuthenticated);
return (
<NavigationContainer>
<RootStack.Navigator screenOptions={{ headerShown: false }}>
{isAuthenticated ? (
<RootStack.Screen name="Main" component={MainTabs} />
) : (
<RootStack.Screen name="Auth" component={LoginScreen} />
)}
</RootStack.Navigator>
</NavigationContainer>
);
}
Claude generates typed navigators correctly — RootStackParamList, nested navigator types, and typed route.params. TypeScript in React Navigation requires param types for type-safe navigation calls.
Deep Linking
Add deep link support for:
- app://product/:id → ProductDetail screen
- app://invite/:token → InviteAccept screen
Claude generates the linking configuration for NavigationContainer with correct path mapping and the required prefix for both iOS (yourapp://) and Android, plus the Universal Links / App Links configuration notes.
State Management with Zustand
Create a Zustand store for the shopping cart.
Actions: addItem, removeItem, updateQuantity, clearCart.
Persist to AsyncStorage.
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
totalItems: () => number;
totalPrice: () => number;
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (newItem) => set(state => {
const existing = state.items.find(i => i.id === newItem.id);
if (existing) {
return {
items: state.items.map(i =>
i.id === newItem.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, { ...newItem, quantity: 1 }] };
}),
removeItem: (id) => set(state => ({
items: state.items.filter(i => i.id !== id),
})),
updateQuantity: (id, quantity) => set(state => ({
items: quantity <= 0
? state.items.filter(i => i.id !== id)
: state.items.map(i => i.id === id ? { ...i, quantity } : i),
})),
clearCart: () => set({ items: [] }),
totalItems: () => get().items.reduce((sum, i) => sum + i.quantity, 0),
totalPrice: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}),
{
name: 'cart-storage',
storage: createJSONStorage(() => AsyncStorage),
}
)
);
persist middleware with AsyncStorage handles hydration on app restart. Zustand’s set pattern with updater functions avoids stale state issues common in React Native (where re-renders are batched differently than web).
Platform-Specific Code
Conditional Styling
The header needs platform-specific padding —
iOS needs more top padding for the status bar,
Android needs a different shadow approach.
import { Platform, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
header: {
paddingTop: Platform.select({
ios: 50, // Status bar is part of layout on iOS
android: 16, // Android uses windowSoftInputMode
}),
// Platform shadow differences
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
android: {
elevation: 4,
},
}),
},
});
Platform-Specific Components
The date picker needs to use DateTimePicker on iOS
and a custom modal on Android.
Claude generates DatePicker.ios.tsx and DatePicker.android.tsx with the shared interface — React Native’s platform extension resolution picks the right file automatically without any runtime branching.
Data Fetching with TanStack Query
Fetch the product list with pagination.
Cache for 5 minutes. Show loading skeleton,
handle errors with a retry button.
import { useInfiniteQuery } from '@tanstack/react-query';
import { FlatList, ActivityIndicator, Pressable, Text } from 'react-native';
export function ProductList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
refetch,
} = useInfiniteQuery({
queryKey: ['products'],
queryFn: ({ pageParam = 0 }) => fetchProducts({ skip: pageParam, take: 20 }),
getNextPageParam: (lastPage, allPages) =>
lastPage.hasMore ? allPages.length * 20 : undefined,
staleTime: 5 * 60 * 1000, // 5 minutes
});
if (isLoading) return <ProductListSkeleton />;
if (isError) {
return (
<Pressable onPress={() => refetch()}>
<Text>Failed to load. Tap to retry.</Text>
</Pressable>
);
}
const items = data.pages.flatMap(page => page.products);
return (
<FlatList
data={items}
renderItem={({ item }) => <ProductCard product={item} />}
keyExtractor={item => item.id}
onEndReached={() => hasNextPage && fetchNextPage()}
onEndReachedThreshold={0.5}
ListFooterComponent={isFetchingNextPage ? <ActivityIndicator /> : null}
/>
);
}
Claude uses FlatList (not ScrollView + map) for long lists — essential for performance. onEndReached triggers the infinite scroll load. The pages.flatMap pattern converts the paginated cache into a flat array for the list.
Performance
Memoization
My product grid is re-rendering every item on any state change.
The grid has 200 items with images — it's janky.
Fix the re-render problem.
Claude identifies the issue (parent state change triggers all renders) and applies the correct fixes:
// Memoize the render function
const renderItem = useCallback(
({ item }: { item: Product }) => <ProductCard product={item} />,
[] // ProductCard should be memoized too
);
// Memoize the key extractor
const keyExtractor = useCallback((item: Product) => item.id, []);
// The FlatList
<FlatList
data={products}
renderItem={renderItem}
keyExtractor={keyExtractor}
removeClippedSubviews // Unmount off-screen items
maxToRenderPerBatch={10}
windowSize={10}
/>
// ProductCard wrapped in memo
export const ProductCard = React.memo(function ProductCard({ product }: Props) {
// ...
});
removeClippedSubviews and windowSize tuning are React Native specific — Claude knows these FlatList performance props.
Image Optimization
Images are loading slowly and causing janky scrolling.
We're using expo-image. What configuration should I use?
Claude configures expo-image with proper caching policy, placeholder blur hash for loading states, and contentFit for correct aspect ratios — expo-image’s API differs from the built-in Image component and Claude knows the difference.
Testing React Native
Write tests for the CartItem component.
It shows product name, price, quantity controls (+/-),
and a remove button.
import { render, fireEvent, screen } from '@testing-library/react-native';
describe('CartItem', () => {
const mockItem = {
id: '1', name: 'Widget', price: 1999, quantity: 2,
};
it('displays item details', () => {
render(<CartItem item={mockItem} onUpdateQuantity={jest.fn()} onRemove={jest.fn()} />);
expect(screen.getByText('Widget')).toBeTruthy();
expect(screen.getByText('$19.99')).toBeTruthy();
expect(screen.getByText('2')).toBeTruthy();
});
it('calls onUpdateQuantity when + tapped', () => {
const onUpdate = jest.fn();
render(<CartItem item={mockItem} onUpdateQuantity={onUpdate} onRemove={jest.fn()} />);
fireEvent.press(screen.getByTestId('quantity-increase'));
expect(onUpdate).toHaveBeenCalledWith('1', 3);
});
it('calls onRemove when quantity decremented to 0', () => {
const onRemove = jest.fn();
const singleItem = { ...mockItem, quantity: 1 };
render(<CartItem item={singleItem} onUpdateQuantity={jest.fn()} onRemove={onRemove} />);
fireEvent.press(screen.getByTestId('quantity-decrease'));
expect(onRemove).toHaveBeenCalledWith('1');
});
});
React Native Testing Library uses fireEvent.press (not click). Claude generates mobile-correct tests rather than web testing patterns.
Common Build Issues
Claude Code is effective for React Native build debugging when you paste the full error:
Metro bundler error:
"Unable to resolve module @react-navigation/native
from screens/HomeScreen.tsx"
Claude identifies: missing pod install after adding a package (iOS), or npx react-native start --reset-cache needed after config changes.
For Xcode build failures (ld: library not found for...), Claude reads the error, identifies the missing native module link, and provides the correct pod install or npx react-native run-ios fix.
React Native with Claude Code
Mobile development’s main friction is the native layer — the gap between JavaScript and the device APIs. Claude Code handles this well because:
- It knows which packages require native modules vs pure JS
- It generates type-safe React Navigation code that doesn’t require runtime trial-and-error
- It knows Expo module APIs (different from bare React Native APIs)
- It understands iOS/Android behavioral differences that aren’t obvious from docs
For web React patterns that apply to React Native’s component model, see the React frontend guide. For comprehensive mobile skill sets covering push notifications, in-app purchases, offline sync, and native module wrappers, the Claude Skills 360 bundle includes React Native skill patterns built from production mobile apps. Start with the free tier to try the navigation and state management patterns.