Lists + virtualization cheatsheet.

Keys

{items.map((it) => <Item key={it.id} item={it} />)}
  • Use stable IDs.
  • NEVER use index for reorderable / inserted lists.
  • Index is OK for truly static append-only lists.

Why: React uses keys to match old vs new elements. Mismatched keys → wrong child state.

TanStack Virtual

npm i @tanstack/react-virtual
import { useVirtualizer } from "@tanstack/react-virtual";

function List({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  
  const v = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 40,        // estimated row height
    overscan: 5,
  });
  
  return (
    <div ref={parentRef} style={{ height: 400, overflow: "auto" }}>
      <div style={{ height: v.getTotalSize(), position: "relative" }}>
        {v.getVirtualItems().map((row) => (
          <div
            key={row.key}
            style={{
              position: "absolute",
              top: 0, left: 0, width: "100%",
              height: row.size,
              transform: `translateY(${row.start}px)`,
            }}
          >
            {items[row.index].name}
          </div>
        ))}
      </div>
    </div>
  );
}

10k items → ~20 DOM nodes.

Dynamic row height

const v = useVirtualizer({
  count: items.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 40,
  measureElement: (el) => el.getBoundingClientRect().height,
});

// In render: pass measure ref
<div ref={v.measureElement} data-index={row.index} key={row.key} />

Measures actual height per row.

Horizontal list

const v = useVirtualizer({
  count,
  horizontal: true,
  getScrollElement: () => ref.current,
  estimateSize: () => 100,
});

Grid

Use useVirtualizer twice (rows + columns) or use useWindowVirtualizer for full-page.

Infinite scroll

const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ ... });

const all = data?.pages.flatMap(p => p.items) ?? [];

const v = useVirtualizer({
  count: hasNextPage ? all.length + 1 : all.length,
  getScrollElement: () => ref.current,
  estimateSize: () => 40,
});

useEffect(() => {
  const last = v.getVirtualItems().at(-1);
  if (!last) return;
  if (last.index >= all.length - 1 && hasNextPage) fetchNextPage();
}, [v.getVirtualItems(), hasNextPage]);

react-window (older alternative)

npm i react-window
import { FixedSizeList } from "react-window";

<FixedSizeList height={400} itemCount={items.length} itemSize={40} width="100%">
  {({ index, style }) => <div style={style}>{items[index].name}</div>}
</FixedSizeList>

Simpler API, less flexible than TanStack Virtual.

IntersectionObserver for sentinel

const sentinelRef = useRef<HTMLDivElement>(null);

useEffect(() => {
  if (!sentinelRef.current) return;
  const io = new IntersectionObserver(([entry]) => {
    if (entry.isIntersecting) fetchNextPage();
  });
  io.observe(sentinelRef.current);
  return () => io.disconnect();
}, [fetchNextPage]);

// Bottom of list:
<div ref={sentinelRef} />

Pairs with non-virtualized “load more” lists.

Item memoization

const Item = React.memo(function Item({ item }) {
  return <div>{item.name}</div>;
});

For long lists where individual items re-render heavy, prevents subtree re-renders.

List update patterns

// Add
setItems((arr) => [...arr, newItem]);

// Remove
setItems((arr) => arr.filter((x) => x.id !== id));

// Update one
setItems((arr) => arr.map((x) => x.id === id ? { ...x, name } : x));

// Reorder (move idx 2 to idx 5)
setItems((arr) => {
  const copy = [...arr];
  const [moved] = copy.splice(2, 1);
  copy.splice(5, 0, moved);
  return copy;
});

When to virtualize

  • Lists with 100+ items.
  • Slow scrolling or input lag.
  • DOM count contributing to perf issues.

Don’t virtualize tiny lists — overhead outweighs benefit.

Avoiding cumulative layout shift

// Reserve space for unloaded items:
<div style={{ minHeight: items.length * 40 }}>
  ...
</div>

Or use content-visibility: auto CSS for cheap skip:

.list-item { content-visibility: auto; contain-intrinsic-size: 40px; }

Browser-native virtualization-ish.

Common mistakes

  • Index keys + reorderable list → mysterious bugs.
  • Items not memoized in 1000-row list → laggy scroll.
  • Wrong estimateSize → jumpy scrollbar.
  • Inline functions in virtualized item → re-render storms.
  • Spread props through many memoized layers → identity changes.

Read this next

If you want my virtualized list + infinite scroll snippets, they’re at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .