← Recipes

阅读中文版本

Virtualize a 10,000-Row List Without Breaking Search or Scroll

Render only the visible window plus overscan, restore scroll position on navigation, and keep typeahead search working against the full dataset.

~40 minfrontendreactvirtualization

Task

Window a huge list so DOM nodes stay constant, scroll feels native, and search/filter jumps to matching rows correctly.

When to use this

The admin table renders 10,000 DOM rows: an eight-second freeze on load, janky scrolling, and a search box that types one character per second.

Prerequisites

  • React 18+ (or a framework with a virtualized list library)
  • A dataset of thousands of rows with stable unique ids
  • A design that needs search, filtering, and deep-linkable scroll state

Steps

1. Measure the current DOM cost as the baseline.

echo "DevTools > Elements: document.querySelectorAll('*').length; and Performance > record scroll"; node -e "console.log('rows:', 10000, 'x ~12 nodes/row = ~120k DOM nodes')"

Expected output: The node count prints (six figures), and the scroll recording shows long yellow scripting blocks per frame, establishing what must improve.

2. Window the list with a virtualization adapter.

import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef, useMemo } from 'react';

function AdminTable({ rows }: { rows: Row[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  // index lookup: O(1) from row.id -> position in the filtered array
  const idToIndex = useMemo(() => {
    const m = new Map<string, number>();
    rows.forEach((r, i) => m.set(r.id, i));
    return m;
  }, [rows]);

  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 44,        // measured row height
    overscan: 8,                   // render beyond the viewport
  });

  return (
    <div ref={parentRef} style={{ height: '80vh', overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map((vi) => (
          <Row key={rows[vi.index].id}
               row={rows[vi.index]}
               style={{ position: 'absolute', top: 0,
                        transform: `translateY(${vi.start}px)`,
                        height: vi.size }} />
        ))}
      </div>
    </div>
  );
}

Expected output: The DOM holds roughly viewport-height/44 plus 16 rows regardless of dataset size; scrolling renders only the entering slice.

3. Keep search working against the full dataset, not the window.

const [query, setQuery] = useState('');

// filter BEFORE virtualization: the window is a view, not the data
const filtered = useMemo(() => {
  if (!query.trim()) return rows;
  const q = query.toLowerCase();
  return rows.filter((r) => r.email.toLowerCase().includes(q) || r.name.toLowerCase().includes(q));
}, [rows, query]);

// jump to first match as the user types
useEffect(() => {
  if (!query) return;
  const idx = idToIndex.get(filtered[0]?.id);
  if (idx !== undefined) virtualizer.scrollToIndex(idx, { align: 'start' });
}, [query, filtered, idToIndex, virtualizer]);

Expected output: Typeahead filters the entire array in memory and the viewport jumps to the first hit, with the input staying responsive because no re-render touches 10,000 rows.

4. Restore scroll position from a deep link or back navigation.

// persist: only the row id, never the pixel offset
const onScroll = throttle(() => {
  const first = virtualizer.getVirtualItems()[0];
  if (first) history.replaceState(null, '', `?at=${rows[first.index].id}`);
}, 500);

// restore: id -> index -> scrollToIndex (height-change safe)
useEffect(() => {
  const at = new URLSearchParams(location.search).get('at');
  if (!at) return;
  const idx = idToIndex.get(at);
  if (idx !== undefined) virtualizer.scrollToIndex(idx, { align: 'start' });
}, [idToIndex, virtualizer]);

Expected output: Returning to the table lands on the same record even if row heights changed since the visit, because restoration goes through the stable id, not a stale pixel value.

5. Verify node count, frame rate, and search latency.

echo "reload and run in console:"; echo "document.querySelectorAll('*').length  // ~600 constant"; echo "then: Performance > record 5s scroll, check for long frames; type 4 chars in search, confirm < 100ms response"

Expected output: The DOM node count stays around six hundred regardless of data size, scrolling records no long frames, and search keystrokes respond instantly.

Troubleshooting

Problem

Variable row heights jump or overlap

Solution

estimateSize was a guess; provide a measureElement ref to the virtualizer so it measures real heights, and remeasure on content changes so the scroll total stays truthful.

Problem

Browser find-in-page cannot see unrendered rows

Solution

That is inherent to windowing; route users to your own search box with a visible shortcut hint, and offer an export view for full-text tooling.

Problem

Keyboard paging jumps past rows

Solution

Bind arrow and page keys to scrollToIndex with align: 'auto' instead of native scrolling by pixels, keeping focus, selection, and viewport in agreement.

Limitations

  • Windowing trades away browser-native find and full-DOM semantics; requirements needing the whole tree (printing, some a11y tools) need a rendered fallback view.
  • In-memory filtering scales to tens of thousands of rows; beyond that, server-side search and pagination replace the client filter.

Render the viewport, not the dataset

Ten thousand DOM rows cost eight seconds of parse and layout and make every frame pay for the whole tree. Virtualization renders the visible window plus a small overscan: constant DOM, native-feeling scroll, and a dataset that can grow without the UI noticing.

before: 10,000 rows mounted
  DOM nodes: ~120,000
  first paint: 8.2s
  scroll: 3-5 long frames/s, janky
  search keystroke latency: 900ms

after: windowed (viewport 80vh / 44px + overscan 8)
  DOM nodes: ~600 (constant, dataset-size independent)
  first paint: 380ms
  scroll: 60fps, no long frames
  search: filters full array in useMemo, jumps to first hit

The window is a view; keep logic on the data

Filtering, sorting, and selection must operate on the full array, with the virtualizer as a pure projection of it. Logic that accidentally runs over only the rendered slice works on page one and breaks on page forty; the idToIndex map keeps the translation between data position and window position honest.

Persist ids, not pixels

A saved pixel offset breaks the moment a row height changes or an item is inserted above. Persisting the first visible row id and restoring through scrollToIndex survives height variance, filtering, and data refreshes, which is the difference between deep links that work and deep links that land somewhere odd.