Steven's Knowledge

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: padding works because system handles soft keyboard above the view.
  • Android: depends on android:windowSoftInputMode in AndroidManifest. With adjustResize, the activity already resizes — KeyboardAvoidingView may 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, ~µs

setState 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 changes

Hermes vs JSC Differences

Hermes is the default engine since RN 0.70. Behavior differences to watch for:

AreaHermesJSC
Date parsingStricter ISO-8601Lenient
IntlLimited until RN 0.74Full
Source mapsDifferent format (.hbc)Standard JS
Stack traces in productionNeed react-native-bundle-visualizer + sourcemap uploadStandard

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

ErrorCauseFix
Unable to resolve module after installMetro cache stalenpx react-native start --reset-cache
Duplicate symbol linker errorTwo libs link the same static libUse use_frameworks! consistently in Podfile
Could not determine the dependencies of task (Android)Gradle cachecd android && ./gradlew clean
Pod install hangsCDN issuebundle exec pod install --repo-update
ReferenceError: regeneratorRuntime is not definedBabel preset misconfigEnsure @babel/preset-env targets Hermes

On this page