7 min read
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
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
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
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
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
// 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
// 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.
.claude/skills/react-native-performance/SKILL.mdDrop 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