AI Tools Compared

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

Four tasks that cover the spectrum of React Native work:

  1. Build a swipeable card component with gesture handling
  2. Implement a custom bottom tab bar with haptic feedback
  3. Write a hook for offline-first data sync
  4. 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.