How would you optimize rendering of a React component that handles large datasets?
Layer fixes: (1) virtualize the rendered list (TanStack Virtual / react-window) so DOM stays constant; (2) paginate or cursor-fetch the data so memory stays bounded; (3) memoize row components + stable item props; (4) avoid re-creating object/function literals in JSX; (5) useTransition for filter/sort updates so input stays snappy; (6) move heavy compute (parsing, sorting) off the main thread with web workers. Measure with Profiler before each change.
Big-data React components break for predictable reasons. The fix is layered — apply each layer until the metric (INP, scroll FPS, time-to-interactive) is acceptable.
Step 1: virtualize
Rendering 10k DOM nodes is the largest single perf killer. Virtualization renders only the visible window:
import { useVirtualizer } from '@tanstack/react-virtual';
function BigList({ items }) {
const parentRef = useRef(null);
const v = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 8,
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: v.getTotalSize(), position: 'relative' }}>
{v.getVirtualItems().map(row => (
<Row key={row.key} item={items[row.index]} top={row.start} />
))}
</div>
</div>
);
}DOM size drops from N to ~20-50. Initial render, memory, scroll FPS all win.
Step 2: paginate or cursor-fetch the data
Even with virtualization, holding 1M items in memory is wasteful. Fetch a page at a time:
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['items'],
queryFn: ({ pageParam = 0 }) => api.list({ offset: pageParam, limit: 50 }),
getNextPageParam: (last, all) => last.length === 50 ? all.length * 50 : undefined,
});
const items = data?.pages.flat() ?? [];
useEffect(() => {
const last = virtualizer.getVirtualItems().at(-1);
if (last && last.index >= items.length - 10 && hasNextPage) fetchNextPage();
}, [virtualizer.getVirtualItems(), items.length, hasNextPage]);Step 3: memoize row components
Each scroll changes the visible window → React re-renders. If row components are memoized with stable props, only newly-visible rows actually do work.
const Row = React.memo(function Row({ item }) {
return <div>{item.name}</div>;
});Memoization only helps if props are stable references. New inline object literals (style={{ ... }}) bust it.
Step 4: stable props
// Bad — new function + style every render
{items.map(item => (
<Row key={item.id} item={item} onClick={() => select(item.id)} style={{ ... }} />
))}
// Good — stable handler factory
const onSelect = useCallback((id) => select(id), [select]);
{items.map(item => (
<Row key={item.id} item={item} id={item.id} onClick={onSelect} />
))}Or move the handler inside the row.
Step 5: useTransition for non-urgent updates
When the user types into a search box that filters a big list, the input must stay snappy. useTransition marks the filter-result update as low priority:
const [query, setQuery] = useState('');
const [filtered, setFiltered] = useState(items);
const [isPending, startTransition] = useTransition();
function onChange(e) {
setQuery(e.target.value); // urgent
startTransition(() => {
setFiltered(items.filter(i => i.name.includes(e.target.value)));
});
}Step 6: heavy compute → web worker
If filtering/sorting takes >50ms, the main thread blocks → INP regression. Move to a worker:
const workerRef = useRef(new Worker('filter-worker.js'));
useEffect(() => {
workerRef.current.onmessage = (e) => setFiltered(e.data);
}, []);
function onChange(e) {
setQuery(e.target.value);
workerRef.current.postMessage({ query: e.target.value, items });
}For huge datasets, post the worker a tree-shaken index, not all items, on each query.
Step 7: content-visibility for offscreen sections
If the page has multiple sections, mark offscreen ones content-visibility: auto:
.section { content-visibility: auto; contain-intrinsic-size: 1000px; }Browser skips layout/paint for offscreen sections.
Step 8: avoid React-specific anti-patterns
- Don't render in a loop with non-stable keys (causes remount).
- Don't compute derived data in render — use useMemo or precompute.
- Don't pass new object/array/function refs to memoized children.
- Don't useEffect that depends on the whole items array — depend on length or specific fields.
Step 9: measure with Profiler
React DevTools Profiler shows you which components rendered, why, and how long. Run before each fix; verify it moved the needle.
Step 10: production metrics
Ship web-vitals; track INP and long-task count on the big-list route. The Profiler is dev-only; production INP is what matters.
Common mistakes
- Memoizing without stable props — useless.
- useMemo around primitives or trivial computations — overhead without gain.
- Rendering all items "just in case the user scrolls down" — that's what virtualization solves.
- Using IDs as keys but regenerating IDs each render — full remount.
- Heavy formatting (date, currency) in render without memoizing the formatter.
Mental model
Big-list perf is layered: render fewer DOM nodes (virtualize), hold less data (paginate), do less work per render (memoize), schedule less work on the main thread (useTransition, workers). Measure between each layer; stop when the metric is acceptable.
Follow-up questions
- •How does useTransition differ from debounce?
- •When is virtualization overkill?
- •How would you handle sort/filter of 100k items?
- •What does content-visibility do under the hood?
Common mistakes
- •Memoizing without stable prop references.
- •useMemo around cheap computations.
- •Inline literals busting React.memo.
- •Heavy compute in render — should be memoized or moved to worker.
- •ID regeneration on each render — keys break, full remount.
- •Forgetting to measure with Profiler before/after.
Performance considerations
- •Done right: 60fps scroll, sub-200ms INP, bounded memory even on 100k+ items. Done wrong: 5fps drag, multi-second filter, OOM on long sessions. The layering matters — fix the biggest bottleneck first (almost always rendering with virtualization).
Edge cases
- •Variable-height rows complicate virtualization.
- •Filter/sort UI needs to debounce + transition for snappy input.
- •Cross-list selection state needs careful design.
- •Server-side pagination + client-side virtualization combine but need careful state sync.
- •Real-time updates (WebSocket) into a virtualized list — re-measure and scroll preserve.
Real-world examples
- •Slack message list, Gmail inbox, Linear issue table — all virtualized + paginated.
- •TanStack Table + Virtual for big grids.
- •Excalidraw uses Canvas for huge canvases instead of DOM.