Finding Slow Renders

Why a component re-renders, and when memo, useMemo and useCallback earn their keep - measured first, not guessed.

Syntaxconst Memoized = memo(Component, arePropsEqual?)

React re-renders a component in three cases: its own state changed, its parent re-rendered, or a context it reads changed. A re-render is not a repaint. React re-runs the function, compares the result with the previous one, and only sends the differences to the native views. Most re-renders are therefore cheap, and removing them buys nothing.

Measure before you change anything

Record a session in the React Profiler panel of React Native DevTools, which shows which components committed and how long each took. For a number you can keep, wrap a subtree in React's Profiler: onRender(id, phase, actualDuration, baseDuration, startTime, commitTime). Profiling is disabled in production builds by default, and development builds are slow for unrelated reasons, so treat development timings as relative, not absolute.

The three tools

memo(Component, arePropsEqual?) skips a re-render when props are shallow-equal by Object.is. It does not stop a re-render caused by the component's own state or by context. useMemo(calculate, deps) caches a value between renders; useCallback(fn, deps) caches a function. Those last two are the same mechanism: useCallback(fn, deps) is shorthand for useMemo(() => fn, deps).

What trips people up: passing style={{ padding: 8 }} or an inline arrow to a memoized child. Each render creates a new object, the shallow comparison fails, and the memo never hits. Hoist styles into StyleSheet.create and wrap handlers in useCallback. Expo's experimental reactCompiler option inserts this memoization for you; DevTools marks those components with a Memo badge.

Example

Example · tsx
import { memo, useCallback } from "react";
import { FlatList, StyleSheet, Text, View } from "react-native";
import type { ListRenderItem } from "react-native";

type Contact = { id: string; name: string; company: string };

type ContactRowProps = { contact: Contact };

// memo skips this row's re-render while `contact` is the same object it was.
// Rows are the one place memo reliably pays off: the parent re-renders often
// and each row's own props rarely change.
const ContactRow = memo(function ContactRow({ contact }: ContactRowProps) {
  return (
    <View style={styles.row}>
      <Text style={styles.name}>{contact.name}</Text>
      <Text style={styles.company}>{contact.company}</Text>
    </View>
  );
});

type ContactListProps = { contacts: Contact[] };

export default function ContactList({ contacts }: ContactListProps) {
  // Stable identity, so changing `contacts` is the only thing that reaches
  // the rows. useCallback(fn, deps) is shorthand for useMemo(() => fn, deps).
  const renderItem = useCallback<ListRenderItem<Contact>>(
    ({ item }) => <ContactRow contact={item} />,
    [],
  );

  return (
    <FlatList
      data={contacts}
      keyExtractor={(contact) => contact.id}
      renderItem={renderItem}
    />
  );
}

const styles = StyleSheet.create({
  // Styles live here, not inline, so the row's style prop keeps one identity.
  row: { paddingHorizontal: 16, paddingVertical: 12, gap: 2 },
  name: { fontSize: 16, fontWeight: "600" },
  company: { fontSize: 13, color: "#6b7280" },
});

When to use it

  • A list stutters while the user types in the search box above it.
  • Every keystroke in a form re-renders a whole screen because the value sits in context.
  • Deciding whether an existing useMemo is doing anything, before adding another.
  • Confirming a performance fix with a number rather than a feeling.

More examples

Timing a subtree with Profiler

On screen this is a search field above a list; typing filters it. The Profiler draws nothing. Its onRender callback fires after every commit of the subtree, and this version logs only commits over one frame's budget of about 16ms, which is the line between 'fine' and 'the user felt it'. Use it to confirm a suspicion before optimising, and to check afterwards that the number moved.

Example · tsx
import { Profiler, useState } from "react";
import { StyleSheet, TextInput, View } from "react-native";
import ContactList from "./ContactList";

type Contact = { id: string; name: string; company: string };

type SearchableContactsProps = { contacts: Contact[] };

export default function SearchableContacts({ contacts }: SearchableContactsProps) {
  const [query, setQuery] = useState("");
  const visible = contacts.filter((c) => c.name.includes(query));

  return (
    <View style={styles.container}>
      <TextInput value={query} onChangeText={setQuery} style={styles.input} />
      <Profiler
        id="contact-list"
        onRender={(id, phase, actualDuration) => {
          // phase is "mount", "update" or "nested-update".
          if (__DEV__ && actualDuration > 16) {
            console.log(id, phase, Math.round(actualDuration));
          }
        }}
      >
        <ContactList contacts={visible} />
      </Profiler>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  input: { margin: 16, borderWidth: 1, borderColor: "#d1d5db", borderRadius: 8, padding: 10 },
});

useMemo for work that is actually expensive

A filter and sort over a few thousand items takes long enough to be felt between keystrokes, so caching the result is worth the extra code. The same wrapper around a twenty-item filter is not: useMemo itself costs a comparison and an allocation. Reach for it when the Profiler shows the work, and leave it out otherwise.

Example · tsx
import { useMemo, useState } from "react";
import { FlatList, StyleSheet, Text, TextInput, View } from "react-native";

type Ticket = { id: string; title: string; priority: number };

type TicketBoardProps = { tickets: Ticket[] };

export default function TicketBoard({ tickets }: TicketBoardProps) {
  const [query, setQuery] = useState("");

  // Sorting thousands of tickets on every keystroke is measurable work.
  // The deps array says: redo this only when tickets or query change,
  // not when some unrelated state in this component changes.
  const sorted = useMemo(() => {
    return tickets
      .filter((t) => t.title.toLowerCase().includes(query.toLowerCase()))
      .sort((a, b) => b.priority - a.priority);
  }, [tickets, query]);

  return (
    <View style={styles.container}>
      <TextInput value={query} onChangeText={setQuery} style={styles.input} />
      <FlatList
        data={sorted}
        keyExtractor={(ticket) => ticket.id}
        renderItem={({ item }) => <Text style={styles.row}>{item.title}</Text>}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  input: { margin: 16, borderWidth: 1, borderColor: "#d1d5db", borderRadius: 8, padding: 10 },
  row: { paddingHorizontal: 16, paddingVertical: 10 },
});

A memo that never hits, and the fix

A card with an Add button and a number under it; each tap raises the number. The commented-out line is the most common memoization bug in React Native code: the child is wrapped in memo, the parent passes an inline arrow or an inline style object, and the shallow comparison fails every time. useCallback gives the handler one identity, and StyleSheet.create does the same for styles.

Example · tsx
import { memo, useCallback, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";

type ToggleProps = { label: string; onPress: () => void };

const Toggle = memo(function Toggle({ label, onPress }: ToggleProps) {
  return (
    <Pressable onPress={onPress} style={styles.button}>
      <Text style={styles.text}>{label}</Text>
    </Pressable>
  );
});

export default function Panel() {
  const [count, setCount] = useState(0);

  // BROKEN: a fresh arrow function every render, so memo always re-renders.
  //   <Toggle label="Add" onPress={() => setCount((c) => c + 1)} />
  // Also broken, for the same reason: style={{ marginTop: 8 }} on a memo child.

  // FIXED: one identity for the life of the component. The updater form
  // setCount((c) => c + 1) means `count` is not a dependency.
  const increment = useCallback(() => setCount((c) => c + 1), []);

  return (
    <View style={styles.container}>
      <Toggle label="Add" onPress={increment} />
      <Text>{count}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12 },
  button: { backgroundColor: "#111827", paddingHorizontal: 16, paddingVertical: 10, borderRadius: 8 },
  text: { color: "#ffffff" },
});

Discussion

  • Be the first to comment on this lesson.