How would you optimize React rendering performance in large lists (e.g., 10k+ rows)
Virtualize (TanStack Virtual / react-window) — render only visible rows. Memoize the row component. Stable keys. Avoid layout reads in row render. Defer non-urgent updates with `useDeferredValue` or `startTransition`. For variable heights use measurement cache. Server-side filter/sort for huge datasets so the client only handles a window. Don't memoize the list itself — focus on rows.
Levers
1. Virtualization (the big win)
import { useVirtualizer } from '@tanstack/react-virtual';
function List({ items }) {
const parentRef = useRef(null);
const v = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
overscan: 8,
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: v.getTotalSize(), position: 'relative' }}>
{v.getVirtualItems().map((vi) => (
<Row key={items[vi.index].id} item={items[vi.index]} style={{ position: 'absolute', top: vi.start, height: vi.size, width: '100%' }} />
))}
</div>
</div>
);
}DOM nodes ~constant; scrolling stays smooth.
2. Memoize the Row
const Row = React.memo(({ item }) => <div>{item.label}</div>);Memo so unrelated state changes don't re-render every visible row.
3. Stable keys
Use item.id, not array index. Indices break memo identity across reorders / filters.
4. Stable prop refs
Callbacks passed to Row should be stable:
const onSelect = useCallback((id) => dispatch(select(id)), []);Otherwise memo bails.
5. Avoid layout reads in row render
Reading offsetWidth in a row → forced sync layout on every render.
6. Defer non-urgent updates
For filters / search:
const deferred = useDeferredValue(query);
const filtered = useMemo(() => items.filter(...), [items, deferred]);Or startTransition around the filter set.
7. Server-side filter/sort/page
If items is 100k+, don't ship them all. Cursor + server filter. Client only handles a window.
8. Variable row heights
TanStack Virtual measures rows as they render; updates total height. For known heights, set estimateSize accurately.
9. Worker for derivation
Big sort/filter on 100k items → Web Worker. Comlink for ergonomic message passing.
10. Suspense + transitions for fetch
Background-fetch new pages with useTransition so the visible list stays interactive.
What NOT to do
- Memoize the list component (it always re-renders when items change).
- Sprinkle
useCallbackon handlers that aren't passed to memoized children. - Virtualize 50-row lists (overhead > gain).
Measurement
React Profiler: time on render. Watch for rows re-rendering when they shouldn't. Chrome Performance: scroll FPS; long tasks during scroll. Memory: heap should be ~constant under scroll.
Interview framing
"Virtualize — the single biggest win on 10k+ rows; render only the visible window + overscan. Memoize the Row component with stable keys (item.id) and stable callback refs from the parent. Avoid layout reads in row render to prevent forced sync layout. Defer non-urgent updates (filter/sort) with useDeferredValue or startTransition so input stays snappy. For massive data, server-side filter/sort/paginate — don't ship 100k rows to the client. Web Worker for expensive derivations. Adopt TanStack Virtual instead of rolling your own — variable heights, sticky headers, anchored prepends are full of edge cases."
Follow-up questions
- •When is virtualization the wrong choice?
- •How do you handle variable row heights?
- •Why memoize Row but not the List?
Common mistakes
- •Virtualizing small lists.
- •Index keys.
- •Unstable callback refs.
- •Layout reads in row render.
Performance considerations
- •O(visible) DOM nodes; memo keeps re-renders scoped.
Edge cases
- •Anchored prepend (chat scrolling up).
- •Sticky headers.
- •Variable heights with measurement.
- •Ctrl-F doesn't find unrendered rows.
Real-world examples
- •Slack message lists, Linear issue lists, Notion databases.