Back to React
React
medium
mid

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.

9 min read·~15 min to think through

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:

tsx
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:

tsx
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.

tsx
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

tsx
// 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:

tsx
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:

tsx
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:

css
.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.

Senior engineer discussion

Seniors think in layers: render count, data volume, per-render work, work scheduling. They reach for virtualization + pagination first, then memoization, then concurrent features. They measure with Profiler in dev and INP in production.

Related questions