Pitfalls
React Native common pitfalls — bridge cost, FlatList misuse, gesture conflicts, Hermes/JSC, lifecycle
Pitfalls
Real-world React Native bugs and how to avoid them. Each item maps to a class of issue that takes hours to debug if you have not seen it before.
Bridge Serialization Cost
Symptom: Smooth animations on simulator but laggy on real Android. Touching anything that crosses the JS↔native bridge during animation tanks frame rate.
Cause: Every JS↔native call serializes arguments to JSON. Old Architecture batches and posts these — gestures and animations driven from JS pay this cost on every frame.
// Bad: each onScroll round-trips to JS and back
<ScrollView onScroll={(e) => setOffset(e.nativeEvent.contentOffset.y)} />
// Good: scroll value stays on the UI thread via Reanimated
import Animated, { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated';
const offset = useSharedValue(0);
const onScroll = useAnimatedScrollHandler({
onScroll: (e) => { offset.value = e.contentOffset.y; },
});
<Animated.ScrollView onScroll={onScroll} scrollEventThrottle={16} />Rule of thumb: animations and gestures use Reanimated useSharedValue / useAnimatedStyle or useNativeDriver: true. Anything that runs every frame must not call into JS.
useNativeDriver: false Defeats Animated
Using the classic Animated API without useNativeDriver: true runs interpolation on the JS thread. If the JS thread is busy, frames drop.
// Bad
Animated.timing(opacity, { toValue: 1, duration: 200 }).start();
// Good
Animated.timing(opacity, { toValue: 1, duration: 200, useNativeDriver: true }).start();useNativeDriver: true works for opacity, transform, and similar — but not for width, height, backgroundColor. For those, switch to Reanimated v3 (which can animate any property on the UI thread via worklets).
FlatList Misuse
Missing keyExtractor
// Bad: React falls back to indices; reordering causes full re-renders
<FlatList data={items} renderItem={renderItem} />
// Good
<FlatList data={items} keyExtractor={(item) => item.id} renderItem={renderItem} />Inline renderItem
Inline functions break React.memo'd cell shallow comparison.
// Bad: new function every render
<FlatList renderItem={({ item }) => <Row item={item} />} />
// Good
const renderItem = useCallback(({ item }: { item: Order }) => <Row item={item} />, []);
<FlatList renderItem={renderItem} />Missing getItemLayout for fixed-height rows
const ITEM_HEIGHT = 64;
<FlatList
data={items}
getItemLayout={(_, index) => ({ length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index, index })}
renderItem={renderItem}
/>Without getItemLayout, scrolling to index N must measure every preceding row.
Long lists: prefer @shopify/flash-list. FlashList recycles cells (like RecyclerView / UICollectionView) instead of mounting/unmounting React components. For 1000+ rows or heterogeneous data, the difference is dramatic.
Gesture and Scroll Conflicts
Mixing the legacy PanResponder with react-native-gesture-handler inside a scroll view almost always misbehaves. Pick one system per screen.
// Good: gesture-handler everywhere; native gesture composition
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
const pan = Gesture.Pan().onUpdate((e) => { /* ... */ });
const native = Gesture.Native(); // lets ScrollView take over vertical scroll
const composed = Gesture.Simultaneous(native, pan);
<GestureDetector gesture={composed}>
<ScrollView>...</ScrollView>
</GestureDetector>SafeArea Done Wrong
SafeAreaView from react-native is buggy on Android (doesn't pad correctly) and limited on iOS (no per-edge control). Use react-native-safe-area-context.
// Bad: doesn't handle Android notch reliably
import { SafeAreaView } from 'react-native';
// Good
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
// Even better: explicit edges
<SafeAreaView edges={['top', 'left', 'right']}>...</SafeAreaView>Provider must wrap the app root:
import { SafeAreaProvider } from 'react-native-safe-area-context';
<SafeAreaProvider>
<App />
</SafeAreaProvider>Keyboard Avoidance
KeyboardAvoidingView works differently per platform:
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 64 : 0}
>
...
</KeyboardAvoidingView>- iOS:
paddingworks because system handles soft keyboard above the view. - Android: depends on
android:windowSoftInputModein AndroidManifest. WithadjustResize, the activity already resizes —KeyboardAvoidingViewmay double-apply.
For complex screens, react-native-keyboard-controller is more predictable.
State Updates on Unmounted Components
// Bad: async update after unmount logs a warning (legacy) or just silently leaks
useEffect(() => {
fetchData().then(setData);
}, []);
// Good: AbortController or mounted flag
useEffect(() => {
const ac = new AbortController();
fetchData(ac.signal).then(setData).catch((e) => {
if (e.name !== 'AbortError') throw e;
});
return () => ac.abort();
}, []);Better still: use TanStack Query, which handles cancellation automatically.
AsyncStorage as a Hot Path
AsyncStorage is asynchronous, JSON-serialized, and slow. Calling it on every render or on every gesture stalls the JS thread.
// Bad: blocking app boot
const settings = await AsyncStorage.getItem('settings');
// Good: MMKV is sync and 10–30× faster
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV();
const settings = storage.getString('settings'); // sync, ~µssetState During Render
Direct calls in the function body re-trigger render and may infinite loop.
// Bad
function Screen({ id }: Props) {
const [count, setCount] = useState(0);
if (id) setCount(1); // re-renders forever
}
// Good: in effect, or derive from props
useEffect(() => { if (id) setCount(1); }, [id]);
// Or: const count = id ? 1 : 0;Context Over-Subscription
A single Context with a fat object re-renders every consumer when any field changes.
// Bad: every consumer rerenders on theme change AND user change
<AppContext.Provider value={{ theme, user, setUser, setTheme }}>...
// Good: split contexts, or use zustand selectors
const useUser = create<UserState>(...);
const user = useUser((s) => s.user); // resubscribe only when user changesHermes vs JSC Differences
Hermes is the default engine since RN 0.70. Behavior differences to watch for:
| Area | Hermes | JSC |
|---|---|---|
| Date parsing | Stricter ISO-8601 | Lenient |
Intl | Limited until RN 0.74 | Full |
| Source maps | Different format (.hbc) | Standard JS |
| Stack traces in production | Need react-native-bundle-visualizer + sourcemap upload | Standard |
Always enable enableHermes: true unless you have a specific reason — startup is faster, memory is lower, TTI is better.
Memory Leaks from Subscriptions
// Bad: AppState listener never removed
useEffect(() => {
AppState.addEventListener('change', handler);
}, []);
// Good
useEffect(() => {
const sub = AppState.addEventListener('change', handler);
return () => sub.remove();
}, []);The same pattern applies to Linking, NetInfo, Keyboard listeners.
Image Memory Blowup
<Image> decodes at the source resolution. A 4000×3000 product image on a list of 50 items is 240 MB of bitmaps.
// Bad
<Image source={{ uri: hugePhotoUrl }} style={{ width: 100, height: 100 }} />
// Good: ask the CDN for a thumbnail; use expo-image for cache + transitions
<Image source={{ uri: `${hugePhotoUrl}?w=200` }} style={{ width: 100, height: 100 }} />For lots of images, switch to expo-image — disk cache, blurhash placeholders, memory pressure handling.
Re-render of the Whole Screen From a Single State Change
In a long screen with many siblings, lifting state to the parent makes all siblings re-render.
// Bad: typing in a field re-renders the entire page
function Screen() {
const [name, setName] = useState('');
return (
<View>
<ExpensiveHeader />
<TextInput value={name} onChangeText={setName} />
<ExpensiveList />
</View>
);
}
// Good: keep input state local, lift only on submit
// Or use react-hook-form (uncontrolled)Forgetting removeClippedSubviews
For very long vertical lists on Android, removeClippedSubviews={true} detaches offscreen views. Don't use it with horizontal lists or animated views — it has known bugs.
Build Failures You'll Hit at Least Once
| Error | Cause | Fix |
|---|---|---|
Unable to resolve module after install | Metro cache stale | npx react-native start --reset-cache |
Duplicate symbol linker error | Two libs link the same static lib | Use use_frameworks! consistently in Podfile |
Could not determine the dependencies of task (Android) | Gradle cache | cd android && ./gradlew clean |
| Pod install hangs | CDN issue | bundle exec pod install --repo-update |
ReferenceError: regeneratorRuntime is not defined | Babel preset misconfig | Ensure @babel/preset-env targets Hermes |