In a React Native FlashList marketplace grid, recycled cells show the wrong favourite state after scrolling; why, and how do you fix it?
answer
- initial state is read once
- recycled cell never remounts
- derive from data where possible
- useRecyclingState resets on deps
- a key prop fix kills recycling
basics
~20 sFlashList reuses a mounted cell for a new listing, and useState's initial value is read only at mount, so the previous listing's favourite flag survives. Derive the flag from data, or use useRecyclingState keyed on item.id.
solid answer
~50 sThe row does `useState(item.isFavourite)`. `useState` reads its initial value once, when the component mounts. FlashList does not remount a cell for a new item - it re-renders the same component with a different `item` - so the state still holds the flag of whichever listing the cell showed before, and a user sees hearts on listings they never saved. The best fix is to stop keeping data in row state: read the flag from the item or from a favourites store keyed by listing id. For genuinely local UI state, FlashList's `useRecyclingState(initial, [item.id])` resets the value during the render in which the deps change, with no extra render and no flash of the old value. Two tempting fixes are worse: `key={item.id}` resets state by forcing a remount, which defeats recycling, and a `useEffect` reset renders the stale value first.
code
tsx · 16 linesimport { Pressable, Text } from 'react-native';
import { useState } from 'react';
type Listing = { id: string; title: string; isFavourite: boolean };
// Buggy inside FlashList: the initial value is read only when the cell mounts,
// so a recycled cell keeps the previous listing's flag.
export function ListingCell({ item }: { item: Listing }) {
const [favourite, setFavourite] = useState(item.isFavourite);
return (
<Pressable onPress={() => setFavourite(f => !f)}>
<Text>{item.title}</Text>
<Text>{favourite ? 'Saved' : 'Save'}</Text>
</Pressable>
);
}go deeper
Remember that a FlashList cell is reused for other items, so state stored in the row can belong to the previous item.
Explain why useState's initial value is read only at mount and how useRecyclingState resets state when its deps change without an extra render.
Diagnose stale hearts, images or animations as recycling leaks, move data out of row state, and reject the key-prop and effect fixes with their real costs.
Make recycle-safe rows a review rule for list migrations, with data in stores and only transient UI state in cells, so the same bug does not recur per screen.
## The bug A marketplace app shows listings in a two-column FlashList grid, each with a heart to save it. The row component looks reasonable: - it receives `item`; - it keeps `const [favourite, setFavourite] = useState(item.isFavourite)` so the heart can toggle instantly; - it calls an API to persist the change. After scrolling, hearts appear filled on listings the user never saved, and saved ones look empty. Nothing is wrong with the API. ## Why recycling causes it **`useState` reads its initial value once**, when the component mounts. Later renders ignore the argument. In `FlatList` that is harmless, because a row that scrolls out is unmounted and the next one mounts with its own initial value. **FlashList recycles.** Its docs describe the mechanism directly: when an item leaves the viewport, the component is not destroyed but re-rendered with a different `item` prop, and state set while it showed another item is still there. So the cell that showed listing 12 is re-rendered for listing 57 with listing 12's `favourite` value. Anything else stored per component instance behaves the same way: - values in `useRef`, including animation values; - a selected photo index in a card's image carousel; - the scroll position of a nested horizontal list; - timers or subscriptions started for the previous item. ## Fixes, best first 1. **Derive, do not copy.** Whether a listing is saved is data, not UI state. Read it from `item.isFavourite` or from a favourites store keyed by listing id, and update the store on toggle. There is nothing to reset because the cell holds nothing. When the flag lives outside `data`, make sure rows re-render when it changes - rows subscribing to the store themselves, or the value reaching the list through `extraData`. 2. **`useRecyclingState` for genuinely local state.** FlashList's hook has the signature `useRecyclingState(initialState, deps, onReset?)`. When any dependency changes - typically `[item.id]` - it resets the value to `initialState` **during that render**, without an extra `setState`, and calls `onReset` so you can reset other things, such as a nested list's scroll position. Its setter also tells the list to re-layout, like `useLayoutState`, so state that changes the cell's height is handled. 3. **Include the source value in the deps when it can change for the same item.** With `[item.id]` alone, a server update to `item.isFavourite` for the same listing will not reset the local copy; `[item.id, item.isFavourite]` will. ## Where each piece of row state belongs | State in a listing card | Where it should live | |---|---| | saved / favourite flag | the item or a favourites store keyed by listing id | | expanded description that changes the cell's height | `useRecyclingState` (its setter re-lays out the cell) | | selected photo in the card's carousel | `useRecyclingState` keyed on `item.id` | | scroll position of a nested horizontal list | reset in `useRecyclingState`'s `onReset` | | in-flight toggle request | the store or data layer, never the cell | The rule of thumb: if losing the value when the cell is reused would be a bug, it is data and belongs outside the cell; if it is transient presentation, it can live in the cell with a reset. ## Fixes that look right but cost you | Fix | Why it is tempting | What it costs | |---|---|---| | `key={item.id}` on the row | React resets state when a key changes | every reuse becomes a remount, so the list loses its recycling advantage | | `useEffect(() => setFavourite(item.isFavourite), [item.id])` | resets the value after the item changes | the cell first renders with the stale value, then renders again - a visible flicker and double work | | `maxItemsInRecyclePool={0}` | cells stop being reused | off-screen items unmount, so the list loses the reuse it was chosen for | The FlashList docs are explicit about `key`: a `key` inside an item or its nested components that changes between items stops the list from recycling views. ## How to find these bugs before users do - **Audit row components during migration.** The FlashList migration steps include checking every `useState` in the `renderItem` hierarchy. - **Scroll far, then back.** Stale state shows up only after cells have been reused, so a quick test on the first screen misses it. - **Search for `useRef` and `useState` in rows** and ask of each: should this reset when the item changes? ## Version note `useRecyclingState`, `useLayoutState` and `useMappingHelper` were introduced with FlashList v2. On v1, the same reset had to be written by hand, usually with the flicker-prone effect.
- Why is resetting with useEffect worse than useRecyclingState in a FlashList row?An effect runs after the render is committed, so the recycled cell first paints with the previous listing's value and only then schedules a second render with the right one - a visible flicker plus double work on every reuse. `useRecyclingState` compares its deps during render and resets the stored value before returning it, so the first paint for the new item is already correct.
- What is useRecyclingState's onReset callback for?Resetting things that are not the hook's own value when the cell starts showing a different item - the docs' example is resetting the scroll position of a nested horizontal list. It runs when the dependencies change, alongside the value reset, so all per-item leftovers in the cell are cleared in one place.
saying these in an interview costs you the question
- FlashList remounts a cell when it shows a different item, so useState resets.
- Adding key={item.id} to the row is the recommended fix for stale recycled state.
- Resetting row state in a useEffect on item.id is flicker-free.
- Only useState leaks between recycled cells; refs and animation values are safe.
- Setting maxItemsInRecyclePool to 0 is a cheap fix for stale cell state.