Web Development Bug Report

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.

Hannah Okafor Hannah Okafor @hannahokafor · February 20, 2026 · 7 min read · 27 Views
Banner: React.memo list still re-rendered every row because inline objects and functions defeated memoization

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 item objects changing. They came from a query cache and kept their identity between renders; the Profiler did not list item as changed.
  • Context updates. StockRow used 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.memo compares props by reference.
  • Do not pass inline objects, arrays or functions to memoized components.
  • Stabilise callbacks with useCallback along the whole chain of parents.
  • Use useDeferredValue for expensive updates triggered by typing.
  • Virtualise long lists.

References

Add a label

#react #profiler #react-memo #usememo #usecallback
Written by
Hannah Okafor @hannahokafor

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.