All topics

Performance

Lists that stay smooth at ten thousand rows, re-renders you can actually account for, and animations that never touch the JS thread.

Principles

Measure before optimizing, on a real low-end device

A release build on a mid-range Android phone is the only measurement that matters. Debug builds on a flagship simulator will tell you everything is fine right up until a user review says otherwise.

Animate on the UI thread or don't bother

Reanimated worklets and the native driver run animations off the JS thread, so a busy render or a JSON parse cannot stutter them. An `Animated` timing without `useNativeDriver` competes with everything else you're doing.

Memoize the render path of list items, all of it

`React.memo` on a row is defeated by an inline `onPress` arrow or an inline style object in the parent — both create a new reference every render. Memoizing the component without stabilizing its props is a no-op that looks like a fix.

Give lists the information they need to skip work

`keyExtractor` plus a stable `getItemLayout` lets a list skip measurement entirely. Without them the list measures every row on every change, which is exactly the work you were trying to avoid.

Context is a re-render broadcast, not a state manager

Every consumer re-renders when any part of the value changes. Split contexts by update frequency, or the theme provider will re-render your entire tree whenever a form field changes.

Patterns

What goes wrong, what to do instead, and why the difference matters.

List rows that actually memoize

Avoid

tsx
const Row = React.memo(ProductRow);

<FlatList
  data={products}
  renderItem={({ item }) => (
    // New function + new object every single render.
    // React.memo can never bail out.
    <Row item={item} style={{ padding: 12 }} onPress={() => open(item.id)} />
  )}
/>

Three new references per row per render. The memo is decorative.

Prefer

tsx
const styles = StyleSheet.create({ row: { padding: 12 } });

const Row = React.memo(function ProductRow({ item, onPress }: Props) {
  // The row calls back with its own id, so the parent's
  // handler never needs to close over the item.
  return <Pressable onPress={() => onPress(item.id)} style={styles.row} />;
});

const handlePress = useCallback((id: string) => open(id), [open]);
const renderItem = useCallback(
  ({ item }: { item: Product }) => <Row item={item} onPress={handlePress} />,
  [handlePress],
);

<FlatList
  data={products}
  renderItem={renderItem}
  keyExtractor={(item) => item.id}
  getItemLayout={(_, index) => ({ length: ROW_H, offset: ROW_H * index, index })}
/>

Stable references all the way down, plus a fixed row height so the list can skip measurement.

Animation off the JS thread

Avoid

tsx
const opacity = useRef(new Animated.Value(0)).current;

Animated.timing(opacity, {
  toValue: 1,
  duration: 300,
  // Defaults to false — every frame is computed in JS and
  // bridged across. A busy thread means dropped frames.
}).start();

The animation competes with your data parsing, your navigation transition, and every other bit of JS work.

Prefer

tsx
const opacity = useSharedValue(0);

const style = useAnimatedStyle(() => ({
  opacity: withTiming(opacity.value, { duration: 300 }),
}));

// Runs entirely on the UI thread. A blocked JS thread
// cannot stutter it.
<Animated.View style={style} />

Reanimated worklets run on the UI thread, so JS work and animation smoothness stop being coupled.

Splitting context by update frequency

Avoid

tsx
// One provider, one value object.
<AppContext.Provider value={{ theme, user, cart, setCart }}>
  {children}
</AppContext.Provider>

// Adding a cart item re-renders every consumer of theme.

A new object literal every render, and one shared subscription for values that change at wildly different rates.

Prefer

tsx
// Rarely changes — separate provider.
<ThemeContext.Provider value={theme}>
  {/* Changes constantly — its own provider, memoized value. */}
  <CartContext.Provider value={cartValue}>
    {children}
  </CartContext.Provider>
</ThemeContext.Provider>

const cartValue = useMemo(() => ({ cart, setCart }), [cart, setCart]);

Consumers only re-render for the slice they actually read.

Review checklist

What I look for when reviewing a pull request that touches this area.

  • Profiled in a release build on a mid-range Android device.
  • Every list has `keyExtractor`, and `getItemLayout` wherever rows are fixed-height.
  • No inline functions, objects, or array literals in `renderItem`.
  • All animations use Reanimated worklets or `useNativeDriver: true`.
  • Contexts are split by update frequency, and every provider value is memoized.
  • Expensive screens are verified with the React DevTools profiler, not by eye.

Take the skill

Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.

Download
.claude/skills/react-native-performance/SKILL.md

Drop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.

---
name: react-native-performance
description: >-
  Diagnose and fix React Native performance: list virtualization, re-render
  elimination, memoization that actually works, native-thread animation, and
  context splitting. Use when a screen janks or a list stutters.
argument-hint: "[screen or list]"
license: MIT
metadata:
  author: John Felix Lim
  version: "1.0.0"
---
# Performance

Apply when something feels slow, and always measure first. Most React Native performance work is removing unnecessary re-renders and moving animation off the JS thread — reach for those before anything more exotic.

## Rules

1. **Measure before optimizing, on a real low-end device** — A release build on a mid-range Android phone is the only measurement that matters. Debug builds on a flagship simulator will tell you everything is fine right up until a user review says otherwise.
2. **Animate on the UI thread or don't bother** — Reanimated worklets and the native driver run animations off the JS thread, so a busy render or a JSON parse cannot stutter them. An `Animated` timing without `useNativeDriver` competes with everything else you're doing.
3. **Memoize the render path of list items, all of it** — `React.memo` on a row is defeated by an inline `onPress` arrow or an inline style object in the parent — both create a new reference every render. Memoizing the component without stabilizing its props is a no-op that looks like a fix.
4. **Give lists the information they need to skip work** — `keyExtractor` plus a stable `getItemLayout` lets a list skip measurement entirely. Without them the list measures every row on every change, which is exactly the work you were trying to avoid.
5. **Context is a re-render broadcast, not a state manager** — Every consumer re-renders when any part of the value changes. Split contexts by update frequency, or the theme provider will re-render your entire tree whenever a form field changes.

## Patterns

### List rows that actually memoize

**Avoid** — Three new references per row per render. The memo is decorative.

```tsx
const Row = React.memo(ProductRow);

<FlatList
  data={products}
  renderItem={({ item }) => (
    // New function + new object every single render.
    // React.memo can never bail out.
    <Row item={item} style={{ padding: 12 }} onPress={() => open(item.id)} />
  )}
/>
```

**Prefer** — Stable references all the way down, plus a fixed row height so the list can skip measurement.

```tsx
const styles = StyleSheet.create({ row: { padding: 12 } });

const Row = React.memo(function ProductRow({ item, onPress }: Props) {
  // The row calls back with its own id, so the parent's
  // handler never needs to close over the item.
  return <Pressable onPress={() => onPress(item.id)} style={styles.row} />;
});

const handlePress = useCallback((id: string) => open(id), [open]);
const renderItem = useCallback(
  ({ item }: { item: Product }) => <Row item={item} onPress={handlePress} />,
  [handlePress],
);

<FlatList
  data={products}
  renderItem={renderItem}
  keyExtractor={(item) => item.id}
  getItemLayout={(_, index) => ({ length: ROW_H, offset: ROW_H * index, index })}
/>
```

### Animation off the JS thread

**Avoid** — The animation competes with your data parsing, your navigation transition, and every other bit of JS work.

```tsx
const opacity = useRef(new Animated.Value(0)).current;

Animated.timing(opacity, {
  toValue: 1,
  duration: 300,
  // Defaults to false — every frame is computed in JS and
  // bridged across. A busy thread means dropped frames.
}).start();
```

**Prefer** — Reanimated worklets run on the UI thread, so JS work and animation smoothness stop being coupled.

```tsx
const opacity = useSharedValue(0);

const style = useAnimatedStyle(() => ({
  opacity: withTiming(opacity.value, { duration: 300 }),
}));

// Runs entirely on the UI thread. A blocked JS thread
// cannot stutter it.
<Animated.View style={style} />
```

### Splitting context by update frequency

**Avoid** — A new object literal every render, and one shared subscription for values that change at wildly different rates.

```tsx
// One provider, one value object.
<AppContext.Provider value={{ theme, user, cart, setCart }}>
  {children}
</AppContext.Provider>

// Adding a cart item re-renders every consumer of theme.
```

**Prefer** — Consumers only re-render for the slice they actually read.

```tsx
// Rarely changes — separate provider.
<ThemeContext.Provider value={theme}>
  {/* Changes constantly — its own provider, memoized value. */}
  <CartContext.Provider value={cartValue}>
    {children}
  </CartContext.Provider>
</ThemeContext.Provider>

const cartValue = useMemo(() => ({ cart, setCart }), [cart, setCart]);
```

## Review checklist

- [ ] Profiled in a release build on a mid-range Android device.
- [ ] Every list has `keyExtractor`, and `getItemLayout` wherever rows are fixed-height.
- [ ] No inline functions, objects, or array literals in `renderItem`.
- [ ] All animations use Reanimated worklets or `useNativeDriver: true`.
- [ ] Contexts are split by update frequency, and every provider value is memoized.
- [ ] Expensive screens are verified with the React DevTools profiler, not by eye.

---

From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim