skip to content

In a React Native product FlatList where every row reads the whole cart from a store, why does adding one sneaker re-render every rendered row, and how do you narrow it?

level: seniorimportance: should knowfreq 42%

answer

  1. a selector's result decides the re-render
  2. whole cart means a new reference each time
  3. select a primitive per row
  4. rows subscribe, the list stays still
  5. extraData={cart} undoes the win

basics

~20 s

A store re-renders a subscriber when its selected result changes, and the whole cart is a new object after every update. Give each row a selector for a primitive about its own sneaker, such as its quantity.

solid answer

~50 s

Zustand, Redux Toolkit's `useSelector` and Jotai atoms all decide re-renders by comparing what a component selected before and after a change. If each `FlatList` row selects the whole `cart`, every add produces a new cart object, every rendered row sees a changed result and re-renders, and a `FlatList` keeps more rows rendered than are on screen. On a low-end Android phone that is a burst of JavaScript-thread work on every tap. The narrow version selects a primitive per row, for example the quantity for this sneaker's id; after an add, one row's number changes and only that row re-renders. Selectors that derive arrays or objects must return stable references, through memoised selectors or shallow comparison, or they re-render on every change too. And because each row now subscribes itself, the list needs no `extraData={cart}`, which would force the `FlatList` to re-render its rows again.

code

tsx · 28 lines
tsx
import { memo } from 'react';
import { Pressable, Text, View } from 'react-native';
import { create } from 'zustand';

type CartState = {
  quantities: Record<string, number>;
  add: (id: string) => void;
};

export const useCart = create<CartState>()((set) => ({
  quantities: {},
  add: (id) =>
    set((s) => ({ quantities: { ...s.quantities, [id]: (s.quantities[id] ?? 0) + 1 } })),
}));

export const SneakerRow = memo(function SneakerRow({ id, name }: { id: string; name: string }) {
  const quantity = useCart((s) => s.quantities[id] ?? 0); // a number: only this row reacts
  const add = useCart((s) => s.add); // a stable function: never triggers a re-render
  return (
    <View>
      <Text>{name}</Text>
      <Text>In cart: {quantity}</Text>
      <Pressable onPress={() => add(id)}>
        <Text>Add</Text>
      </Pressable>
    </View>
  );
});

go deeper

for a junior

Recall that a component using a store re-renders when what it selects changes, so select only what the component shows.

for a middle

Explain reference comparison, why the whole cart or a filtered array always looks new, and why primitives do not.

for a senior

Design per-row subscriptions for a large list, avoid extraData coupling, and confirm the result on a low-end Android release build.

for a principal

Make selector discipline a codebase convention with shared, memoised selectors, so list performance does not depend on each author remembering it.

## How a store decides who re-renders External stores used in React Native apps, such as **Zustand**, **Redux Toolkit** with React-Redux's `useSelector`, and **Jotai**, share one mechanism under different APIs: a component names the piece of state it needs, and the store re-renders that component only when that piece changes. The comparison is by reference unless you ask for something else. So the **shape of what you select** is what decides the re-render, not which store you picked. ## Why the whole cart re-renders every row Take a sneaker catalogue in a `FlatList`. Each row shows a sneaker and an "In cart: 2" label, and reads the cart like this: select `state.cart`, then look up its own id. Cart updates are immutable, so adding one sneaker creates a new cart object. For every mounted row: 1. the selector runs and returns the new cart object; 2. the store compares it with the previous one, sees a different reference, and schedules a re-render; 3. the row renders, finds its own quantity unchanged, and produces the same output. A `FlatList` keeps a window of rows rendered beyond the visible ones, so this is dozens of rows, each doing work that changes nothing. All of it runs on the JavaScript thread, which on a low-end Android device can be enough to make the add button and any JavaScript-driven animation lag. ## Narrow the selection | Row selects | Returns after adding sneaker A | Rows that re-render | |---|---|---| | whole `cart` | a new object | every rendered row | | `cart.items` array | a new array | every rendered row | | quantity for this row's id | a number, changed only for A | row A | | whether this row's id is in the cart | a boolean, changed only if A was absent | row A at most | Rules that keep it narrow: - **select primitives** (numbers, strings, booleans) where you can; they compare by value; - when a selector must derive an array or object, such as the cart lines with prices, return a **stable reference**: a memoised selector (Redux Toolkit ships `createSelector`) or the library's shallow-comparison helper; - select **actions** separately from data, so a row that only calls `add` never re-renders for data changes; - pass rows an **id** and let them select, rather than passing them slices of the cart as props. ## Keep the list itself out of it A `FlatList` is a `PureComponent`: it re-renders its rows only when its props change shallowly. The React Native docs show `extraData` as the way to tell it that something `renderItem` depends on has changed. That is right when rows get data from the parent, but with per-row store subscriptions it is counterproductive: passing `extraData={cart}` makes the list re-render its rows on every cart change, undoing the narrow selectors. Rows that subscribe themselves update without the list knowing. ## How to confirm it - Log or count renders per row while adding one sneaker; one row should re-render, not all of them. - Test on the slowest Android device you support, in a release build; dev builds exaggerate JavaScript-thread cost. - Check the badge and the cart screen separately; they are other subscribers with their own selectors. ## What narrowing does not fix Narrow selectors cut the number of re-renders. They do not make an individual row cheap, and they do not replace list configuration such as item layout and window size, which is a separate tuning exercise. They are the part of list performance that depends on how state is read.

  • A row selects the cart lines it needs as a new filtered array. Why does it still re-render on every change?
    A filter creates a new array each time the selector runs, and the store compares by reference, so the result always looks changed. Return a stable reference instead: memoise the selector so it returns the same array while its inputs are unchanged, or use the library's shallow comparison so equal contents count as unchanged.
  • Why does the cart badge in the tab bar not need the same treatment as the rows?
    It already selects a primitive, the item count. After an add the count changes, so the badge should re-render, and it is one component. The cost worth attacking is many components re-rendering for no visible change, which is the list-row case.

saying these in an interview costs you the question

  • Switching from Context to any store library fixes re-renders by itself
  • Selecting the whole cart is fine because the row only uses one field
  • A selector that filters an array returns the same result when nothing relevant changed
  • Rows subscribed to a store still need extraData={cart} to update
  • Wrapping each row in memo stops store-triggered re-renders