Back to React
React
medium
mid

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.

8 min read·~5 min to think through

Same playbook as 100k-list optimization. Combine virtualization, memoization, and indexed search.

1. Virtualize

tsx
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

tsx
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

tsx
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

tsx
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

tsx
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

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

  1. Virtualize: 10× DOM reduction.
  2. Memo row: scrolling drops to constant cost.
  3. 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.

Senior engineer discussion

Senior framing: 'large list' optimization is a pattern catalog, not a single technique. Know the catalog, profile, apply the right one. Don't reach for memoization first — profile to confirm where the cost actually lies.

Related questions