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.

Create a free account to save this page, take notes and track your progress. Sign in

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.

Counting row re-renders: inline callback vs stable callbackTry it yourself
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.
  • memo with 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 / useDeferredValue for 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.

Was this page helpful?