Niall Eccles
Back to blog

Virtualised Lists with TanStack Virtual - Handling 10,000 Thumbnails

How I used @tanstack/react-virtual to keep a photo filmstrip smooth at any folder size

19 April 2026
React TypeScript Electron Side Projects foco

The filmstrip in Foco shows every photo in the open folder as a scrollable row of thumbnails. A typical folder has a few hundred images. A folder from a shoot or a camera import can have several thousand. The filmstrip needs to handle both without any difference in behaviour.

Rendering 200 thumbnails is fine. Each is an 80×80 <img> element with a couple of event listeners and a Zustand selector subscription. The browser handles that without complaint. Rendering 2,000 is where things start to feel heavy on mount. Beyond that, the initial render causes visible jank and the memory footprint climbs with every image in the list.

The solution is virtualisation. Only the thumbnails currently visible (plus a small buffer either side) exist in the DOM at any time. The container is sized to the full scrollable length, and each visible item is positioned absolutely at the correct offset. To the user it looks like a normal scrolling list. To the browser, there are never more than about twenty nodes.

Setting up the virtualizer

TanStack Virtual handles the offset calculations. You give it the total item count, a function to measure the scroll container, and a size estimate per item. It gives back a list of virtual items that are currently in range.

const ITEM_SIZE = 84 // 80px thumbnail + 4px gap
const PADDING = 8

const virtualizer = useVirtualizer({
  count: activeImages.length,
  getScrollElement: () => viewportRef.current,
  estimateSize: () => ITEM_SIZE,
  horizontal: isHorizontal,
  overscan: 5,
  paddingStart: PADDING,
  paddingEnd: PADDING
})

estimateSize returns 84, the 80px thumbnail plus the 4px gap between items. All thumbnails are the same size, so this is a constant rather than a function that measures each element.

horizontal switches the scroll axis. The filmstrip in Foco can be docked to the bottom of the window or to the left or right side. When it’s at the bottom, the strip scrolls horizontally. On the sides, it scrolls vertically. The same virtualizer handles both orientations because TanStack Virtual treats horizontal as a simple axis toggle.

overscan: 5 pre-renders five items beyond each edge of the viewport. Without this, scrolling fast enough can expose blank slots before new items have time to render. Five is enough buffer that this does not happen in practice.

Rendering virtual items

The render loop is straightforward. The outer container is sized to getTotalSize(), which gives the browser the full scrollable extent so the scrollbar is accurate. Each virtual item is positioned absolutely at virtualItem.start.

<div ref={viewportRef} style={viewportStyle}>
  <div style={{
    width: virtualizer.getTotalSize(),  // or height, for vertical
    position: 'relative'
  }}>
    {virtualizer.getVirtualItems().map((virtualItem) => {
      const image = activeImages[virtualItem.index]
      return (
        <div
          key={image.path}
          style={isHorizontal
            ? { position: 'absolute', left: virtualItem.start, top: 0, width: 80, height: '100%' }
            : { position: 'absolute', top: virtualItem.start, left: 0, width: '100%', height: 80 }
          }
        >
          <Thumbnail
            image={image}
            isSelected={virtualItem.index === activeIndex}
            index={virtualItem.index}
            folderPath={folderPath}
            onClick={setActiveIndex}
          />
        </div>
      )
    })}
  </div>
</div>

The key is image.path rather than the virtual item index. If you key by index, React reuses the same DOM node for whichever image happens to be at that position during a given render, which causes stale content during fast scrolls.

Scrolling to the selected image

When the user navigates with the arrow keys, the filmstrip needs to follow. If the selected image scrolls out of view, the user loses their place.

useEffect(() => {
  if (activeImages.length > 0) {
    virtualizer.scrollToIndex(activeIndex, { behavior: 'smooth', align: 'center' })
  }
}, [activeIndex])

TanStack Virtual handles the offset calculation internally. It knows the size of every item and the current scroll position, so it can compute exactly how far to scroll to bring a given index into view. align: 'center' keeps the selected thumbnail in the middle of the strip rather than at an edge.

How this interacts with lazy loading

Each Thumbnail component uses an IntersectionObserver to decide when to request its thumbnail from the main process. The observer fires when the element scrolls close to the viewport and triggers a call to the thumbnail queue covered in a previous post.

Virtualisation and lazy loading are complementary. Without virtualisation, a folder of 2,000 images creates 2,000 DOM nodes, 2,000 observers, and 2,000 Zustand selector subscriptions on mount. The observers then fire as the user scrolls, feeding the queue steadily. This works, but the mount cost is paid upfront regardless of how many images the user actually looks at.

With virtualisation, there are never more than roughly twenty DOM nodes. That means twenty observers at most, and only the images the user actually scrolls past ever enter the queue. The combination keeps both mount time and peak memory low.

The one subtlety is that IntersectionObserver needs a real DOM node to observe. Because virtual items are positioned absolutely inside a scrollable container rather than flowing normally, the rootMargin in the observer needs to account for this. The current rootMargin: '200px' was chosen to pre-load thumbnails before they enter the visible area, and it works correctly with the virtualised layout.

Testing without a real viewport

jsdom has no layout engine. When you run useVirtualizer in a test environment, getVirtualItems() returns an empty array because the scroll container has no dimensions. The tests for FilmStrip mock the virtualizer to return all items as if they were visible:

vi.mock('@tanstack/react-virtual', () => ({
  useVirtualizer: vi.fn((options: { count: number; estimateSize: () => number }) => ({
    getVirtualItems: () =>
      Array.from({ length: options.count }, (_, i) => ({
        index: i,
        start: i * options.estimateSize(),
        size: options.estimateSize(),
        key: i,
      })),
    getTotalSize: () => options.count * options.estimateSize(),
    scrollToIndex: vi.fn(),
  })),
}))

This lets the tests assert on selection state, click handlers, and view mode switching without needing a real browser. The mock reflects the actual shape of the virtualizer API, so if the component code changes how it uses getVirtualItems or scrollToIndex, the tests will catch the mismatch.

What I would do differently

estimateSize being a constant is a reasonable simplification here because every thumbnail is 80×80. If the items varied in size, a masonry layout, or a list mixing portrait and landscape thumbnails, you would need measureElement instead. TanStack Virtual supports this, but it makes the code noticeably more complex: you have to attach a measurement ref to each item and let the virtualizer recalculate offsets as elements mount. The constant approach is worth using for as long as you can get away with it.

The overscan: 5 value was chosen by feel. Five items felt like enough to prevent visible blanks during normal scrolling, and the cost of rendering five extra items each side is negligible. The right way to validate this would be to profile at different overscan values with a large folder, which I have not done.

The filmstrip is fast enough that it has not been a source of complaints. Virtualisation, lazy loading, and the thumbnail queue together mean that opening a folder of any size the app is likely to encounter feels the same as opening a small one. The three pieces are each solving a different part of the same problem, DOM count, observer count, and IPC throughput. None of them would be sufficient on its own.

Related Projects

foco

2026

A fast, keyboard-driven desktop app for photo triage and light editing. Open a folder, cull your shots, crop and resize, and review EXIF metadata without leaving your machine.

Electron
React
TypeScript
Sharp
Project details
View project details
Back to blog