React.memo list still re-rendered every row: inline objects and functions defeated memoization
We wrapped each row of a 1,500-item list in React.memo, yet typing in a filter box re-rendered every row. Each render passed a new style object and a new onSelect function, so the shallow prop comparison always failed. How we found it with the Profiler and fixed it with stable references.
Our warehouse dashboard shows a list of about 1,500 stock items with a filter box on top. Typing in the
filter felt sluggish, with a visible delay after each key. A developer had already wrapped the row
component in React.memo months earlier, specifically to avoid this. It did not help.
Profiling a keystroke
In React DevTools, we enabled "Record why each component rendered while profiling" and typed one
character. The flame chart showed StockRow rendered 1,500 times, each with the reason:
Why did this render?
Props changed: (style, onSelect)
The data for each row had not changed, but two props had.
The parent component
function StockList({ items, onSelectItem }: Props) {
const [query, setQuery] = useState('');
const visible = items.filter(i => i.name.toLowerCase().includes(query.toLowerCase()));
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} placeholder="Filter" />
<ul>
{visible.map(item => (
<StockRow
key={item.sku}
item={item}
style={{ padding: 8, borderBottom: '1px solid #eee' }}
onSelect={() => onSelectItem(item.sku)}
/>
))}
</ul>
</>
);
}
const StockRow = React.memo(function StockRow({ item, style, onSelect }: RowProps) {
return (
<li style={style} onClick={onSelect}>
{item.name} <span>{item.quantity}</span>
</li>
);
});
Each render of StockList creates a new style object and a new arrow function for every row. They have
the same content as before, but they are different objects.
What we ruled out
- The
itemobjects changing. They came from a query cache and kept their identity between renders; the Profiler did not listitemas changed. - Context updates.
StockRowused no context. - The memo comparison being disabled in development. Memoization works in development; the Profiler in a production profiling build showed the same result.
Root cause
React.memo skips a re-render only when every prop is equal to its previous value by Object.is. Inline
object literals and arrow functions are new values on every parent render, so the comparison failed for
every row on every keystroke, and memoization never had a chance to work.
Shallow comparison
React.memo(Component) compares each prop of the new render with the previous one using Object.is.
For primitives (strings, numbers, booleans) that means equal values. For objects, arrays and functions,
it means the same reference. Two objects with identical content are not equal:
Object.is({ padding: 8 }, { padding: 8 }); // false
Object.is(() => 1, () => 1); // false
So memoization depends on parents passing stable references.
Where new references come from
| Source | Example | Stable alternative |
|---|---|---|
| Inline object or array literals | style={{ padding: 8 }} |
Constant outside the component, or CSS class |
| Inline functions | onClick={() => select(id)} |
useCallback, or pass id and a stable handler |
| Derived arrays | items.filter(...) |
useMemo with dependencies |
| Spread props | {...rest} containing new objects |
Avoid, or memoize the source |
Who is affected
Any code that wraps components in React.memo and passes them inline objects, arrays, functions or JSX
children. JSX passed as children is a new element object on every render, so a memoized component that
takes children re-renders whenever its parent does. Context values built inline in a provider cause the
same problem for every consumer. Libraries that accept style, options or config objects are common
sources.
Measuring before optimising
The Profiler's "why did this render" information is the quickest way to find these cases. Without it,
teams often add more React.memo wrappers, which do nothing when props are unstable and add comparison
cost to every render.
Environment
| Component | Version / value |
|---|---|
| React | 19.0 |
| List | ~1,500 rows, no virtualisation |
| Cost | ~45 ms per keystroke re-rendering all rows on a mid-range laptop |
| Compiler | React Compiler not enabled in this app |
Stable style: use a class
The style object never changed, so it did not belong in props at all:
<li className="stock-row" onClick={onSelect}>...</li>
.stock-row { padding: 8px; border-bottom: 1px solid #eee; }
Stable handler: pass the id, not a closure
Instead of creating a closure per row, the row receives a single stable callback and calls it with its own SKU:
function StockList({ items, onSelectItem }: Props) {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const visible = useMemo(
() => items.filter(i => i.name.toLowerCase().includes(deferredQuery.toLowerCase())),
[items, deferredQuery]
);
const handleSelect = useCallback((sku: string) => onSelectItem(sku), [onSelectItem]);
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} placeholder="Filter" />
<ul>
{visible.map(item => (
<StockRow key={item.sku} item={item} onSelect={handleSelect} />
))}
</ul>
</>
);
}
const StockRow = React.memo(function StockRow({ item, onSelect }: RowProps) {
return (
<li className="stock-row" onClick={() => onSelect(item.sku)}>
{item.name} <span>{item.quantity}</span>
</li>
);
});
The inline arrow inside StockRow is fine: it is created only when StockRow itself renders, which now
happens only when its item changes.
handleSelect is only stable if onSelectItem from the grandparent is stable. We checked the parent and
wrapped it in useCallback there too. Memoization is a chain; one unstable link breaks it.
Keep typing responsive
useDeferredValue lets React render the input immediately with the new text and update the filtered list
at a lower priority. Even when filtering takes time, typing never waits for it.
Consider virtualisation
Only about 30 rows are visible at once. Rendering 1,500 is wasteful even when they are memoized. A virtualised list renders the visible rows plus a small buffer:
import { useVirtualizer } from '@tanstack/react-virtual';
// render only virtualizer.getVirtualItems() inside a fixed-height scroll container
We added virtualisation later, for the initial render cost on page load.
Checking the whole chain
To find every unstable prop, we temporarily logged prop identity changes in the row during development:
const prev = useRef<RowProps>();
useEffect(() => {
if (prev.current) {
for (const k of Object.keys(props) as (keyof RowProps)[]) {
if (!Object.is(prev.current[k], props[k])) console.debug('changed prop', k);
}
}
prev.current = props;
});
It showed onSelect still changing after the first fix, which led to the grandparent's unstable callback.
Alternatives and trade-offs
- React Compiler memoizes these values automatically, including the inline style and closures, if the code follows the rules of React. In a newer app we would enable it instead of adding hooks by hand.
- A custom comparison function for
React.memo. Possible, but easy to get wrong and to forget to update when props change.
How to test the fix
A render-count test instruments the row with a counter through a test-only wrapper:
const renders = new Map<string, number>();
jest.mock('./StockRow', () => {
const Actual = jest.requireActual('./StockRow').StockRow;
return {
StockRow: React.memo((props: RowProps) => {
renders.set(props.item.sku, (renders.get(props.item.sku) ?? 0) + 1);
return <Actual {...props} />;
}),
};
});
test('typing re-renders only rows whose visibility changes', async () => {
render(<StockList items={fixture1500} onSelectItem={stableFn} />);
renders.clear();
await user.type(screen.getByPlaceholderText('Filter'), 'z');
expect(renders.size).toBeLessThan(50);
});
We also used the DevTools Profiler on a production profiling build as the main verification.
Confirming in production
After the change, a keystroke re-rendered the input and the list container, plus only rows entering or leaving the filtered set. Time per keystroke dropped from about 45 ms to under 5 ms, and the input felt instant. Interaction to Next Paint for the page improved from 210 ms to 70 ms at p75.
Regression guard
- A custom lint rule flags inline object, array and function props passed to components wrapped in
React.memo. - A performance budget test in CI renders the list with 1,500 items and fails if a keystroke takes more than 16 ms in the test environment.
Security implications
None.
Prevention checklist
- Remember that
React.memocompares props by reference. - Do not pass inline objects, arrays or functions to memoized components.
- Stabilise callbacks with
useCallbackalong the whole chain of parents. - Use
useDeferredValuefor expensive updates triggered by typing. - Virtualise long lists.
References
- React, memo: https://react.dev/reference/react/memo
- React, useCallback: https://react.dev/reference/react/useCallback
- React, useDeferredValue: https://react.dev/reference/react/useDeferredValue
- React DevTools Profiler: https://react.dev/reference/react/Profiler
Add a label
Embed this post
<iframe src="https://www.hibug.me/react-memo-rerenders-inline-object-props" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I am a frontend engineer who has worked on dashboards, booking flows and design systems, mostly in React and TypeScript. I care about the parts of the web platform that frameworks hide: forms, cookies, rendering cost and what the browser actually does with our code. The bugs I write about usually passed every review and only appeared with production data: a map with a million points, a name with an accent in a PDF, a cookie that one subdomain never sees. Each post shows a minimal reproduction, why the browser or framework behaves that way, and a fix with a test.
Related Stories
CloudFront signed cookies set, every request still 403: cookie domain, path and key groups
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.