skip to content

In a React Native FlatList, why should the row component returned by renderItem usually be wrapped in React.memo?

level: juniorimportance: should knowfreq 58%

answer

  1. many rows mounted at once
  2. every cell re-runs renderItem
  3. shallow prop comparison
  4. immutable updates to data

basics

~20 s

When a FlatList re-renders, every mounted cell calls renderItem again, which can mean hundreds of rows. Wrapping the row in React.memo lets rows with unchanged props skip rendering, so one updated item re-renders one row.

solid answer

~40 s

A `FlatList` keeps a large window of rows mounted, by default up to ten screens either side of the viewport. When the list re-renders, for example because `data` changed after one price tick, every mounted cell calls `renderItem` again, since `FlatList` hands its cells a freshly built renderer. Without `React.memo`, every row component then re-renders. With a memoized row that receives primitive props, and `data` updated immutably so unchanged items keep their object identity, only the changed row renders. Mutating an item in place breaks this for a memoized row that takes the whole `item` object: it sees the same reference and shows stale data. Memo does not keep rows alive, though; rows leaving the window are unmounted and lose their local state.

code

tsx · 25 lines
tsx
import { memo, useCallback } from 'react';
import { FlatList, Text, View } from 'react-native';

type Quote = { symbol: string; priceText: string };

const QuoteRow = memo(function QuoteRow({ symbol, priceText }: Quote) {
  return (
    <View style={{ flexDirection: 'row', height: 56 }}>
      <Text>{symbol}</Text>
      <Text>{priceText}</Text>
    </View>
  );
});

export function applyTick(quotes: Quote[], symbol: string, priceText: string): Quote[] {
  return quotes.map((q) => (q.symbol === symbol ? { ...q, priceText } : q));
}

export function QuoteList({ quotes }: { quotes: Quote[] }) {
  const renderItem = useCallback(
    ({ item }: { item: Quote }) => <QuoteRow symbol={item.symbol} priceText={item.priceText} />,
    [],
  );
  return <FlatList data={quotes} keyExtractor={(q) => q.symbol} renderItem={renderItem} />;
}

go deeper

for a junior

Recall that list rows should be their own component wrapped in React.memo, receiving simple props, with data updated by replacing items rather than mutating them.

for a middle

Explain that FlatList re-runs renderItem for every mounted cell on each re-render, and why shallow comparison of props is what lets unchanged rows skip.

for a senior

Diagnose stale rows from in-place mutation and state lost on unmount, and decide which data belongs in item data or a store instead of row state.

for a principal

Set list conventions for the team, such as memoized row components and immutable feed updates, and weigh them against the cost of enforcing them everywhere.

## Why this matters more in a list than elsewhere A React Native `FlatList` keeps many rows mounted at once. With the default `windowSize` of 21, that is the visible screen plus up to ten screens of rows before and after it, which can easily be a couple of hundred rows. Whatever happens to one row on a re-render happens to all of them. When the parent passes a new `data` array (for example because one stock price ticked), `FlatList` re-renders. Looking at the list's source shows how that reaches the rows: 1. `FlatList` wraps your `renderItem` in its own renderer function, and by default it builds a **fresh wrapper on each of its renders**. 2. Each mounted row sits inside an internal **cell renderer** that is a pure component. It receives that wrapper as a prop, sees a new function, and re-renders. 3. Re-rendering the cell calls `renderItem` again, which returns your row element. 4. React then decides whether your **row component** re-renders. A plain function component always does. A component wrapped in **`React.memo`** is skipped when its props are shallow-equal to last time. So without `memo`, a single price tick re-runs the render function of every mounted row. With `memo`, and props that really are unchanged for the other rows, only the row whose price changed renders. ## What makes the memo effective `React.memo` compares props by reference (`Object.is` per prop). Three habits make it skip exactly the right rows: - **Immutable updates to `data`**: replace the changed item with a new object and keep every other item object as it was. The unchanged rows then receive identical `item` references. - **Primitive or stable props**: passing `symbol` and `priceText` strings is the most robust shape; functions and inline objects created during render defeat the comparison. - **Outside state goes through `extraData`**: if a row depends on something not in `data` (a selected symbol, a theme), the list must be told via `extraData`, and the row must receive that value as a prop. The details of which props are unstable and how to fix them belong to render-cost profiling; the list-specific point is that the list will re-run every cell on each re-render, so the row component is the only place where the work can be stopped. ## The mutation trap If the price feed **mutates** items in place, what breaks depends on how far the mutation goes and on whether the memoized row receives the whole `item` object or primitives read from it: | Update style | FlatList re-renders? | Memoized row re-renders? | Result | |---|---|---|---| | New array, new object for the changed item | Yes | Only the changed row | Correct and cheap | | New array, same mutated object | Yes | Not if the row takes `item` itself, because it is the same reference | Stale price on screen | | Same array mutated in place | No, the list itself sees equal props | No | Nothing updates | ## Row state and the window Memoization does not keep rows alive. Rows that scroll out of the render window are **unmounted**, and any `useState` inside them is lost. The docs say so directly: internal state is not preserved when content scrolls out of the render window. Anything a row must remember, such as an expanded state or a user's selection, belongs in the item data or in a store outside the list, keyed by the row's id. ## A reasonable default For a `FlatList` with more than a screenful of rows: - Define the row as a separate component wrapped in `memo`. - Pass it primitives from the item, not the whole parent state. - Keep `renderItem` itself outside the JSX (for example with `useCallback`) so it is not rebuilt for no reason. - Update `data` immutably. This is cheap to do, and the savings grow with the size of the window. Measure the effect rather than assuming it: a profiler recording of one tick should show one row rendering, not the whole window.

  • A row's expanded state resets when the user scrolls far away and back; why, and what is the fix?
    The row left the render window and was unmounted, so its `useState` was discarded; remounting starts fresh. `React.memo` cannot prevent that. Keep the expanded flag in the item data or in a store keyed by the row's id, and pass it to the row as a prop.
  • Why can mutating an item in place leave a stale price on a memoized row that receives the whole item object?
    `React.memo` compares each prop by reference. A mutated item is still the same object, so a row whose prop is `item` looks unchanged and skips rendering even though the price inside changed. Replacing the changed item with a new object, and leaving the others untouched, gives that row a new reference and keeps the rest skipping.

It is like a trading floor where the manager reads out the whole board every time one price changes: memoized rows are traders who glance at their own symbol and stay seated unless their number moved.

saying these in an interview costs you the question

  • FlatList only re-renders the rows whose items changed on its own
  • React.memo keeps a row mounted after it leaves the window
  • Mutating an item in place is safe for a memoized row that takes the item
  • Memoizing rows matters only for lists with over 10,000 items
  • Only the rows visible on screen re-render when data changes