AniUI Academy

Avoiding Unnecessary Re-Renders

The re-render model is identical to React's, but a dropped frame is more visible on a phone, and an unmemoized FlatList renderItem is a common culprit.

9 min read

Nothing about why a component re-renders is different in React Native — that entire model, from earlier in your React learning, transfers exactly: a component re-renders when its own state changes, its own props change, its parent re-renders, or a context it consumes changes. React.memo, useMemo, and useCallback all work identically here, with the identical shallow-comparison caveats. What's worth a dedicated lesson isn't a new mechanism — it's that the stakes are higher, and the concrete places it bites are React Native-specific.

Same model, higher stakes

On the web, an unnecessary re-render that doesn't actually change the DOM is often genuinely free — a wasted function call, invisible to the user. On a phone, the same wasted re-render competing for time on the JS thread has a more visible failure mode: a dropped frame during a scroll or a gesture is immediately felt by a finger that's actively touching the screen, in a way a background React DOM re-render rarely is on a desktop page someone is just reading. The bar for "does this unnecessary re-render actually matter" is lower on a phone, not because the mechanics changed, but because the consequences of janky frames are more visceral.

This matters most specifically inside scrolling lists, which is exactly where React Native apps spend a large share of their rendering time.

The FlatList renderItem trap

FlatList calls your renderItem function to produce every visible row. If renderItem is defined inline, inside the component that renders the list, it's a brand-new function on every single render of that parent — identical behavior, but a different reference every time:

function ChatScreen({ messages }) {
  const [draft, setDraft] = useState("");
 
  return (
    <FlatList
      data={messages}
      keyExtractor={(item) => item.id}
      // A new function every render of ChatScreen — including every
      // keystroke that updates `draft`, which has nothing to do with the list
      renderItem={({ item }) => <MessageRow message={item} />}
    />
  );
}

Typing into a draft input re-renders ChatScreen, which re-creates renderItem, which — if MessageRow isn't memoized — re-renders every currently visible row, on every keystroke, regardless of whether any message actually changed. On a long list, that's real, avoidable work competing with the very thing the user is trying to do (type).

The fix: the same tools, applied here

The fix is exactly what the React.memo and useCallback lessons already taught — applied to a component that happens to be a list row instead of an arbitrary child:

const MessageRow = memo(function MessageRow({ message }) {
  return (
    <View style={styles.row}>
      <Text>{message.text}</Text>
    </View>
  );
});
 
function ChatScreen({ messages }) {
  const [draft, setDraft] = useState("");
 
  const renderItem = useCallback(
    ({ item }) => <MessageRow message={item} />,
    [] // MessageRow and its own props don't depend on anything from this scope
  );
 
  return (
    <FlatList
      data={messages}
      keyExtractor={(item) => item.id}
      renderItem={renderItem}
    />
  );
}

Two things had to change together, exactly as the React lessons predicted: MessageRow needed React.memo so it can actually skip re-rendering when its own props haven't changed, and renderItem needed useCallback so its reference stays stable across renders of ChatScreen — memoizing only one of the two accomplishes nothing, because a memoized row still "sees" a new function reference on every parent render if renderItem itself isn't stabilized.

Where this generalizes beyond FlatList

The same pattern shows up anywhere a parent re-renders frequently for reasons unrelated to a child that's expensive to render: a details panel next to a frequently-updating counter, a card grid next to a search input, a map marker layer next to live location updates. The diagnosis is always the same question from the React re-render lessons — "does this child's render actually need to happen again, or is it just along for the ride because its parent rendered" — applied to whichever component is visibly janky on a real device, ideally confirmed with a profiler rather than optimized on a hunch.

What to remember

  • The re-render model is identical to React: parent renders, children follow by default; React.memo, useMemo, and useCallback work exactly the same way here.
  • A dropped frame is more visually obvious on a phone the user is touching than the same wasted work often is on a desktop web page — the stakes are higher, not the mechanism.
  • An inline, unmemoized renderItem in a FlatList is re-created on every parent render, defeating any memoization on the row component itself.
  • Fixing it takes both halves together: React.memo on the row component, and useCallback around renderItem (and anything it depends on).
  • Confirm with a profiler before optimizing — the goal is skipping renders that actually cost something, not reflexively wrapping every row in memo.

Check yourself

4 questions · pass 3/4 to unlock Animations and the Native Driver

up to 50
  1. 1.Is the re-render model (parent re-renders → children re-render by default; React.memo/useMemo/useCallback for opting out) any different in React Native compared to React DOM?

  2. 2.Why are the visual consequences of an unnecessary re-render often more noticeable in a React Native app than in a typical web page?

  3. 3.In a FlatList, what commonly causes every visible row to re-render on every scroll-driven state change, even rows whose own data didn't change?

  4. 4.What's the correct combination for actually skipping unnecessary row re-renders in a FlatList, applying what you already know from React.memo and useCallback?

4 left to answer