React Native development has a unique set of AI challenges: the component model is familiar, but platform-specific APIs, StyleSheet constraints, and the gap between web expectations and mobile behavior trip up most AI tools. This guide tests Claude Code, Cursor, and GitHub Copilot on real React Native tasks.
Table of Contents
- The Test Cases
- Task 1: Swipeable Card (Gesture Handler)
- Task 2: Custom Bottom Tab Bar with Haptics
- Task 3: Offline-First Data Sync Hook
- Task 4: FlatList Performance Fix
- Tool Rankings for React Native
- Related Reading
The Test Cases
Four tasks that cover the spectrum of React Native work:
- Build a swipeable card component with gesture handling
- Implement a custom bottom tab bar with haptic feedback
- Write a hook for offline-first data sync
- Fix a common FlatList performance issue
Task 1: Swipeable Card (Gesture Handler)
This requires react-native-gesture-handler and react-native-reanimated — libraries with APIs that frequently trip up AI tools.
Cursor (GPT-4o backend) output:
// Cursor generated this — INCORRECT
import { PanResponder } from 'react-native'; // Wrong: should use gesture handler
const SwipeCard = () => {
const panResponder = PanResponder.create({
onMoveShouldSetPanResponder: () => true,
onPanResponderMove: (_, gesture) => {
// ...
}
});
Cursor defaulted to the deprecated PanResponder API. This works but is not recommended for new projects using the gesture handler library.
Claude Code output:
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import {
GestureDetector,
Gesture,
GestureHandlerRootView
} from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS
} from 'react-native-reanimated';
interface SwipeCardProps {
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
children: React.ReactNode;
}
const SWIPE_THRESHOLD = 120;
export const SwipeCard: React.FC<SwipeCardProps> = ({
onSwipeLeft,
onSwipeRight,
children
}) => {
const translateX = useSharedValue(0);
const rotate = useSharedValue(0);
const panGesture = Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX;
rotate.value = (event.translationX / 300) * 15; // Max 15 degrees
})
.onEnd((event) => {
if (event.translationX > SWIPE_THRESHOLD) {
translateX.value = withSpring(500, {}, () => {
if (onSwipeRight) runOnJS(onSwipeRight)();
});
} else if (event.translationX < -SWIPE_THRESHOLD) {
translateX.value = withSpring(-500, {}, () => {
if (onSwipeLeft) runOnJS(onSwipeLeft)();
});
} else {
translateX.value = withSpring(0);
rotate.value = withSpring(0);
}
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ rotate: `${rotate.value}deg` }
]
}));
return (
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.card, animatedStyle]}>
{children}
</Animated.View>
</GestureDetector>
);
};
const styles = StyleSheet.create({
card: {
width: 340,
height: 480,
borderRadius: 16,
backgroundColor: '#fff',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 5
}
});
Claude correctly used the new GestureDetector API, runOnJS for callbacks from the UI thread, and withSpring for natural animation. It also knew to warn about wrapping with GestureHandlerRootView at the app root.
Task 2: Custom Bottom Tab Bar with Haptics
// Prompt: "Build a custom bottom tab bar with haptic feedback
// that works on both iOS and Android"
// Claude's output
import React, { useCallback } from 'react';
import { Platform, Pressable, StyleSheet, View } from 'react-native';
import * as Haptics from 'expo-haptics';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming
} from 'react-native-reanimated';
interface TabItem {
name: string;
icon: React.ReactNode;
activeIcon: React.ReactNode;
}
interface CustomTabBarProps {
state: { index: number; routes: Array<{ key: string }> };
navigation: any;
tabs: TabItem[];
}
export const CustomTabBar: React.FC<CustomTabBarProps> = ({
state,
navigation,
tabs
}) => {
const scales = tabs.map(() => useSharedValue(1));
const handlePress = useCallback(
(index: number, routeKey: string) => {
// Haptic feedback — iOS uses impact, Android uses notification
if (Platform.OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
} else {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
// Scale animation
scales[index].value = withTiming(0.85, { duration: 100 }, () => {
scales[index].value = withTiming(1, { duration: 100 });
});
const isFocused = state.index === index;
const event = navigation.emit({
type: 'tabPress',
target: routeKey,
canPreventDefault: true
});
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(routeKey);
}
},
[navigation, state.index, scales]
);
return (
<View style={styles.container}>
{tabs.map((tab, index) => {
const isFocused = state.index === index;
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scales[index].value }]
}));
return (
<Pressable
key={tab.name}
onPress={() => handlePress(index, state.routes[index].key)}
style={styles.tab}
accessibilityRole="button"
accessibilityState={{ selected: isFocused }}
accessibilityLabel={tab.name}
>
<Animated.View style={animatedStyle}>
{isFocused ? tab.activeIcon : tab.icon}
</Animated.View>
</Pressable>
);
})}
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
backgroundColor: '#fff',
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#e0e0e0',
paddingBottom: Platform.OS === 'ios' ? 24 : 8, // Safe area
paddingTop: 8
},
tab: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
}
});
Claude handles the iOS vs Android haptics difference and includes the safe area bottom padding without being prompted. Copilot missed both.
Task 3: Offline-First Data Sync Hook
// hooks/useOfflineSync.ts
import { useEffect, useRef, useState, useCallback } from 'react';
import NetInfo from '@react-native-community/netinfo';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface SyncOptions<T> {
key: string;
fetchFn: () => Promise<T>;
syncFn: (data: T) => Promise<void>;
staleDuration?: number; // ms, default 5 min
}
export function useOfflineSync<T>({
key,
fetchFn,
syncFn,
staleDuration = 5 * 60 * 1000
}: SyncOptions<T>) {
const [data, setData] = useState<T | null>(null);
const [isOnline, setIsOnline] = useState(true);
const [isSyncing, setIsSyncing] = useState(false);
const pendingRef = useRef<T | null>(null);
const lastFetchRef = useRef<number>(0);
// Monitor connectivity
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
const online = !!state.isConnected && !!state.isInternetReachable;
setIsOnline(online);
// Sync pending changes when reconnecting
if (online && pendingRef.current !== null) {
syncPendingData();
}
});
return unsubscribe;
}, []);
// Load cached data on mount
useEffect(() => {
AsyncStorage.getItem(`@sync:${key}`)
.then((cached) => {
if (cached) {
const { data: cachedData, timestamp } = JSON.parse(cached);
setData(cachedData);
lastFetchRef.current = timestamp;
}
})
.catch(console.error);
}, [key]);
const syncPendingData = useCallback(async () => {
if (!pendingRef.current || isSyncing) return;
setIsSyncing(true);
try {
await syncFn(pendingRef.current);
pendingRef.current = null;
await AsyncStorage.removeItem(`@pending:${key}`);
} catch (error) {
console.error('Sync failed:', error);
} finally {
setIsSyncing(false);
}
}, [key, syncFn, isSyncing]);
const refresh = useCallback(async (force = false) => {
const now = Date.now();
const isStale = now - lastFetchRef.current > staleDuration;
if (!force && !isStale) return data;
if (!isOnline) return data;
try {
const fresh = await fetchFn();
setData(fresh);
lastFetchRef.current = now;
await AsyncStorage.setItem(
`@sync:${key}`,
JSON.stringify({ data: fresh, timestamp: now })
);
return fresh;
} catch (error) {
console.error('Fetch failed, using cache:', error);
return data;
}
}, [data, fetchFn, isOnline, key, staleDuration]);
const update = useCallback(async (newData: T) => {
setData(newData);
pendingRef.current = newData;
// Cache pending data for persistence across app restarts
await AsyncStorage.setItem(`@pending:${key}`, JSON.stringify(newData));
if (isOnline) {
await syncPendingData();
}
}, [isOnline, key, syncPendingData]);
return { data, isOnline, isSyncing, refresh, update };
}
Task 4: FlatList Performance Fix
This is where AI tools differ most. Given a slow FlatList with 1,000 items:
// Common problematic pattern
<FlatList
data={items}
renderItem={({ item }) => (
<View style={{ padding: 16, backgroundColor: item.selected ? '#blue' : '#white' }}>
<Text>{item.name}</Text>
</View>
)}
/>
Copilot’s fix: Added keyExtractor and getItemLayout. Correct but incomplete.
Claude’s fix: Added keyExtractor, getItemLayout, removeClippedSubviews, maxToRenderPerBatch, windowSize, wrapped renderItem in useCallback, and extracted the item to a separate React.memo component. Also flagged the inline style object as a re-render cause.
// Claude's optimized version
const ITEM_HEIGHT = 72;
const ListItem = React.memo(({ item, onPress }: { item: Item; onPress: (id: string) => void }) => (
<Pressable
onPress={() => onPress(item.id)}
style={[styles.item, item.selected && styles.selectedItem]}
>
<Text style={styles.itemText}>{item.name}</Text>
</Pressable>
));
const ItemList = ({ items, onItemPress }: Props) => {
const renderItem = useCallback(
({ item }: { item: Item }) => (
<ListItem item={item} onPress={onItemPress} />
),
[onItemPress]
);
const getItemLayout = useCallback(
(_: any, index: number) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index
}),
[]
);
return (
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
getItemLayout={getItemLayout}
removeClippedSubviews={true}
maxToRenderPerBatch={10}
windowSize={5}
initialNumToRender={15}
/>
);
};
Tool Rankings for React Native
| Task | Claude Code | Cursor | Copilot |
|---|---|---|---|
| Modern gesture APIs | Excellent | Uses deprecated APIs | Mixed |
| Platform-specific code | Handles automatically | Usually correct | Misses edge cases |
| Reanimated v3 syntax | Correct | Mixes v2/v3 | Often v2 |
| Performance patterns | Basic | Partial | |
| Expo API familiarity | Strong | Good | Good |
| StyleSheet knowledge | Correct | Correct | Correct |
Claude Code wins on React Native-specific knowledge. Cursor is competitive for standard React patterns but stumbles on mobile-specific APIs. Copilot is reliable for simple components but misses performance optimizations.