How would you optimize React performance when handling very large lists or grids?
Virtualize: render only the visible rows (react-window, TanStack Virtual). Memoize each row (React.memo) with stable callbacks (useCallback). Use stable ids for keys. Move filter/sort into useMemo or a Web Worker. Server pagination for very large data. For tables: sticky header outside scroll container, sticky columns via position sticky. Profile to verify 60fps scroll.
Same playbook as 100k-list optimization. Combine virtualization, memoization, and indexed search.
1. Virtualize
import { FixedSizeList } from 'react-window';
<FixedSizeList height={600} itemCount={rows.length} itemSize={48} width="100%">
{({ index, style }) => (
<div style={style}>
<Row item={rows[index]} />
</div>
)}
</FixedSizeList>Only ~30 rows mounted at a time. DOM cost drops by 100×.
For grids: FixedSizeGrid or TanStack Virtual with both axes.
2. Memoize rows
const Row = memo(function Row({ item }: { item: Item }) {
return <td>{item.name}</td>;
});When the parent re-renders, unchanged rows skip work. Important when scrolling triggers parent updates (scrollTop, viewport changes).
3. Stable callbacks
const onSelect = useCallback((id: string) => setSelected(id), []);Pass stable functions to memoized rows — otherwise memo bails.
4. Stable keys
item.id, never index. If items reorder/filter, index keys destroy state in inputs and break animations.
5. Move heavy work off render
const sorted = useMemo(
() => items.slice().sort(compareFn),
[items, compareFn],
);
const filtered = useMemo(
() => sorted.filter(item => item.name.includes(query)),
[sorted, query],
);If sort/filter is genuinely heavy (10k+ items, complex compare), move to a Web Worker.
6. Server pagination / infinite scroll
const { data, fetchNextPage } = useInfiniteQuery({
queryKey: ['rows'],
queryFn: ({ pageParam = 0 }) => fetch(`/rows?cursor=${pageParam}`),
getNextPageParam: last => last.nextCursor,
});
const rows = data?.pages.flatMap(p => p.rows) ?? [];Pair with virtualization: load 50 at a time, render only visible.
7. Defer non-urgent updates
const deferred = useDeferredValue(query);
const filtered = useMemo(() => filter(items, deferred), [items, deferred]);Input stays responsive; filter lags slightly during heavy work.
8. Tables — additional concerns
- Sticky header: place header outside the scrollable body container so it doesn't scroll.
- Sticky columns: position: sticky with the right z-index stack.
- Column resize: rare per row; don't re-render the whole table on drag.
- Selection: track ids in a Set, not booleans per row.
9. Grid-specific
- 2D virtualization (visible rows × visible columns).
- Cell-level memoization if cells render heavy.
- Row height variation: estimate then measure (TanStack Virtual handles this).
10. Profiling
- React DevTools Profiler: confirm only visible rows render.
- Chrome Performance: scroll at 60fps; long tasks < 50ms.
- Memory tab: heap size after loading the full data.
Anti-patterns
- Rendering 100k items hoping CSS overflow saves you.
- Re-creating the filtered array on every render without useMemo.
- Inline row functions:
onClick={() => doStuff(item.id)}breaks memo. - Storing per-row UI state (open/closed) inside each row instead of a parent Set.
Common 'biggest wins'
- Virtualize: 10× DOM reduction.
- Memo row: scrolling drops to constant cost.
- Index for search: filter time drops from 100ms to < 1ms.
Senior framing
Three orthogonal techniques for three bottlenecks: virtualization for DOM size, memoization for re-render cost, indexing for filter/search CPU. Apply only what profiling shows.
Follow-up questions
- •How does TanStack Virtual handle variable row heights?
- •When is a Web Worker worth it for filter/sort?
- •What breaks when you virtualize a table (find-in-page, focus)?
Common mistakes
- •Memoizing rows but passing inline callbacks — memo bails.
- •Forgetting stable keys; index keys destroy state on reorder.
- •Building filter/sort into the render path without useMemo.
Performance considerations
- •DOM is usually the first wall. Then JS (filter/sort). Then network (fetch latency). Virtualization, memoization, and pagination each address a different layer; apply by symptom.
Edge cases
- •Find-in-page (Ctrl+F) only sees rendered DOM — virtualization hides 99% of rows.
- •Sticky columns + virtualization need careful z-index and transform handling.
- •Drag-to-reorder while virtualized requires placeholder management.
Real-world examples
- •GitHub issue tables, Notion database views, Linear issue boards, Stripe Dashboard transactions, every CRM. All virtualize + paginate + index.