Mobile Apps with React Native and Expo · Device Features, Performance, Testing and Shipping
Performance: Keeping the App Fast and Smooth
Find and fix the usual causes of slow screens: unnecessary re-renders, heavy work on the JavaScript thread, oversized images, and long lists. Measure first, then use memo, useCallback and list tuning where they help.
What you'll build
A product list that stutters when you type in its search box, fixed step by step: stop re-rendering every row on each keystroke, move expensive filtering out of the render path, and right-size images.
The idea
Smooth means 60 frames per second (some phones run at 120): about 16 ms per frame. Your React code runs on the JavaScript thread; if it's busy for 200 ms, taps and animations wait.
Common causes and fixes:
| Symptom | Usual cause | Fix |
|---|---|---|
| Typing lags in a screen with a list | Every keystroke re-renders every row | React.memo rows, stable callbacks (useCallback), stable props |
| Slow filter/sort on each render | Expensive computation in render | useMemo, debounce the input, or do it once on load |
| Janky scrolling | Heavy rows, huge images, no virtualisation | Lighter rows, FlatList tuning, thumbnails via expo-image |
| Animations stutter during work | Animations driven from JavaScript | Reanimated / native driver so animations run on the UI thread |
| Slow startup | Too much work before the first screen | Lazy-load screens, defer non-critical work, keep the bundle small |
Measure before optimising: the React DevTools profiler (in Expo's dev tools) shows which components re-render and why; the performance monitor shows JS and UI frame rates. A release build is much faster than development mode, so confirm problems there.
The code
import { memo, useCallback, useDeferredValue, useMemo, useState } from "react";
import { FlatList, Pressable, Text, TextInput } from "react-native";
import { Image } from "expo-image";
type Product = { id: string; name: string; thumb: string };
const Row = memo(function Row({ item, onOpen }: { item: Product; onOpen: (id: string) => void }) {
return (
<Pressable onPress={() => onOpen(item.id)} style={{ flexDirection: "row", gap: 12, padding: 12 }}>
<Image source={item.thumb} style={{ width: 48, height: 48 }} contentFit="cover" />
<Text>{item.name}</Text>
</Pressable>
);
});
export function Products({ all, open }: { all: Product[]; open: (id: string) => void }) {
const [query, setQuery] = useState("");
const deferred = useDeferredValue(query); // typing stays responsive
const visible = useMemo(
() => all.filter((p) => p.name.toLowerCase().includes(deferred.toLowerCase())),
[all, deferred],
);
const onOpen = useCallback((id: string) => open(id), [open]); // same function each render
return (
<>
<TextInput value={query} onChangeText={setQuery} placeholder="Search" style={{ padding: 12 }} />
<FlatList
data={visible}
keyExtractor={(p) => p.id}
renderItem={({ item }) => <Row item={item} onOpen={onOpen} />}
initialNumToRender={12}
windowSize={7}
removeClippedSubviews
/>
</>
);
}
Try the logic
Why do memo and useCallback go together? memo skips a re-render only if every prop is the same as last time. A new arrow function created on every render is never the same, so the memo does nothing. This simulation counts row renders while a user types four characters.
type Props = { id: string; onOpen: (id: string) => void };
function makeMemoRow() {
let lastProps: Props | null = null;
let renders = 0;
return {
render(props: Props) {
const same = lastProps !== null && lastProps.id === props.id && lastProps.onOpen === props.onOpen;
if (!same) renders++; // memo: re-render only when a prop changed (by identity)
lastProps = props;
},
get renders() { return renders; },
};
}
function simulate(stableCallback: boolean, rows: number, keystrokes: number): number {
const rowComponents = Array.from({ length: rows }, makeMemoRow);
const open = (id: string) => { void id; };
for (let render = 0; render <= keystrokes; render++) { // first render + one per keystroke
const onOpen = stableCallback ? open : (id: string) => open(id); // inline = new function each render
rowComponents.forEach((row, i) => row.render({ id: `p${i}`, onOpen }));
}
return rowComponents.reduce((sum, row) => sum + row.renders, 0);
}
console.log("50 rows, 4 keystrokes");
console.log(" inline callback: ", simulate(false, 50, 4), "row renders");
console.log(" useCallback: ", simulate(true, 50, 4), "row renders");
Output
50 rows, 4 keystrokes inline callback: 250 row renders useCallback: 50 row renders
Common mistakes
- Optimising without measuring. Profile first; most components are cheap.
memowith unstable props. New objects, arrays or functions every render defeat it.- Judging speed in development mode. Debug builds are much slower; test a release build.
- Full-size images in lists. A 4000×3000 photo in a 48-pixel row wastes memory and time.
- Big synchronous work on the JS thread (parsing huge JSON, sorting thousands of items) during interactions.
Key points
- Smooth = ~16 ms per frame; keep the JS thread free during interactions.
- Memoised rows + stable callbacks stop needless re-renders.
useMemo/useDeferredValuefor expensive derived data; debounce input.- Tune lists, right-size images, and measure in release builds.
Further reading: Performance overview, Profiling.
Practice questions
Why does React.memo on a list row often fail to prevent re-renders?
Answer
memo compares props by identity. If the parent passes a new inline function, object or array on every render, the props are never 'the same', so every row re-renders anyway. Stabilise them with useCallback/useMemo or move them out of render.
Why should performance be judged in a release build?
Answer
Development builds run extra checks, warnings and debugging tools and don't minify or optimise the JavaScript, so they're much slower. A release build shows real performance.
Open in the app to mark lessons complete, take notes and track your progress.