Skip to content

Data Caching

A virtualized table needs more than a simple data array. It needs:

  • Suspense integration — the initial fetch should suspend the component so a fallback is shown, while subsequent page fetches should be non-blocking.
  • Page-based fetching — only the pages visible in the viewport should be in memory.
  • Sort-aware mutations — when a real-time event arrives (e.g. via WebSocket), new items must be inserted at the correct sorted position, not appended.
  • Count tracking — the total row count must stay accurate across inserts, removes, and server refetches.

useTableCache handles all of this in a single hook.

The cache is keyed by the key you pass to the hook. The key can be a string, number, or an array of both — arrays are joined with '-' internally:

// Simple string key
const cache = useTableCache('users', { ... })
// Combined array key — equivalent to 'users-name-asc'
const cache = useTableCache(['users', sortField, sortDir], { ... })

This is critical for Suspense: when the hook suspends the component, React unmounts and remounts it. The cache survives these cycles — when the component remounts, it finds the existing data and reads it without re-fetching.

Cache entries are cleaned up on unmount. All entries whose key starts with the provided key prefix are deleted.

useTableCache treats the first fetch differently from all later ones:

ScenarioBehavior
No cached pages (first render)The hook throws the fetch promise → React Suspense catches it → your <Suspense fallback> renders. When the promise resolves, the component remounts with page 0 available.
At least one page cached (scroll-triggered)The hook sets loading: true and fetches in the background. The Body render function returns null for unfetched indices, causing SkeletonRow to render in their place.

This means you get automatic Suspense integration for the initial load and seamless skeleton loading for subsequent pages — without any conditional rendering in your component.

Pages are fetched on demand as the user scrolls. The flow works like this:

  1. VirtualTable fires onRangeChange({ start, end }) with the visible row indices.
  2. You pass this to cache.onRangeChange (or spread the cache return onto VirtualTable — see Spread-friendly API).
  3. The cache calculates which page indices cover the range (Math.floor(start / pageSize) through Math.floor(end / pageSize)).
  4. For each uncached, non-inflight page, it calls fetchItems(offset, limit).
  5. When the fetch resolves, the page is stored and a re-render is triggered.
<VirtualTable
{...cache}
rowHeight={40}
>

Pages that have already been fetched are not re-fetched. Duplicate requests for the same page are automatically prevented.

upsert() handles six cases depending on whether the item exists and where it belongs in the sort order:

1. Known item on a cached page → update in-place

Section titled “1. Known item on a cached page → update in-place”

The cache iterates all cached pages, finds the item by ID, and replaces it. No position change, no count change.

2. Known ID, not on a cached page → skip

Section titled “2. Known ID, not on a cached page → skip”

If the cache has seen this ID in a previous fetch but the item isn’t on any currently loaded page, the upsert is skipped — the server already has the correct data.

3. Unknown ID, sorts within a cached page → surgical insert

Section titled “3. Unknown ID, sorts within a cached page → surgical insert”

This is a genuinely new item whose sort position falls within a cached page. The cache binary-searches the correct insertion position and inserts the item. Subsequent contiguous pages are surgically shifted: the last item is popped from each page and unshifted onto the next, maintaining page boundaries without re-fetching.

After insertion:

  • totalCount is incremented by 1 immediately (the item is definitively new).
  • If fetchCount is provided, a debounced server count fetch is triggered to correct any accumulated drift.

4. Unknown ID, sorts before first cached item

Section titled “4. Unknown ID, sorts before first cached item”

The item is not inserted into the cache — it belongs on a page that isn’t loaded. The cache increments pendingAboveCount (the item is above the viewport) and queries fetchCount to update the total. Scroll correction is applied when the count response arrives.

5. Unknown ID, sorts after last cached item

Section titled “5. Unknown ID, sorts after last cached item”

The item is not inserted — it belongs after all loaded data. The cache queries fetchCount to update the total. No scroll correction is needed because the item is below the viewport.

6. Unknown ID, sorts between non-contiguous cached pages

Section titled “6. Unknown ID, sorts between non-contiguous cached pages”

The item falls in a gap between cached pages and is not inserted. The cache queries fetchCount. If the item sorts before the visible range, pendingAboveCount is incremented for later scroll correction.

The cache remembers every item ID it has ever seen across all fetched pages, along with the page index where it was last seen. This is stored as a Map<string, number> and lets the cache distinguish between:

ScenarioAction
Item on a currently loaded pageUpdate in-place
Item seen before, page no longer loadedSkip (no count change)
Item previously removedTreated as new — the tombstone is cleared
Genuinely new itemInsert or defer to server

The stored page index is also used during remove() to determine whether scroll correction is needed — if the item’s last-known page is before the visible range, the removal shifts content above the viewport.

Removed IDs are kept in the map as a tombstone rather than deleted. Whether an ID is known, unknown or already removed is what tells remove() how to treat totalCount — see below.

When fetchCount is provided in the options, the cache calls it (debounced at 150 ms) after upserts for unknown IDs, and after every removal. This replaces the optimistic totalCount adjustment with the authoritative count from the server.

const cache = useTableCache('users', {
// ...
fetchCount: async () => {
const res = await fetch('/api/users/count')
const data = await res.json()
return data.count
},
})

The debounce prevents a burst of real-time events from firing many count requests. Only the last one in a 150 ms window executes.

When the count response arrives, the cache also applies scroll correction: if pendingAboveCount is greater than zero, the cache calls scrollBy(pendingAboveCount * rowHeight) on the VirtualTable handle to keep the visible content stable.

If fetchCount is not provided, the cache falls back to totalCount += 1 on insert and totalCount -= 1 on remove — including for IDs it has never seen, which is where drift comes from. A repeated removal of the same ID is still ignored.

remove(id) deletes an item from the cache without invalidating subsequent pages:

  1. The item’s ID is tombstoned in the knownIds tracking map. A second remove() for the same ID does nothing at all.
  2. If the item is on a cached page, it is spliced out and subsequent contiguous pages are surgically pulled: the first item from each next page fills the gap, maintaining page boundaries. The last contiguous page shrinks by one.
  3. If the item is known but not on a cached page, the cache uses its last-known page index to determine position relative to the viewport.
  4. totalCount is decremented by 1 — but only for an ID the cache has actually seen. For an unknown ID it queries fetchCount instead of guessing, because an unknown ID is either an item on a never-fetched page or an ID that was never in this result set, and decrementing for the latter hides a row the cache still holds.
  5. If the item was above the viewport (either on a cached page before the visible range, or its last-known page is before the visible range), an immediate scroll correction of -rowHeight is applied to prevent layout shift.
cache.remove('user-123')

Unlike upsert (where scroll correction is deferred until fetchCount resolves), remove applies scroll correction immediately for a known ID, because totalCount is decremented on the same render — the virtualizer’s height changes right away.

When items are added or removed above the viewport, the virtualizer’s total height changes, which would shift the visible content up or down. The cache prevents this by calling scrollBy() on the VirtualTable’s imperative handle.

The cache holds a ref to the VirtualTable, and VirtualTable exposes a scrollBy(px) method via useImperativeHandle.

Upsert uses deferred correction: the cache tracks how many items were added above the viewport in pendingAboveCount. When fetchCount resolves, it computes how many of those items actually changed the count (Math.min(pendingAboveCount, countDelta)) and applies the correction.

Remove uses immediate correction: since totalCount is decremented synchronously, the height change happens on the next render. The cache calls scrollBy(-rowHeight) immediately to compensate.

The cache return value is designed to be spread directly onto VirtualTable. The returned object contains totalCount, getItem, onRangeChange, and ref — all of which match VirtualTable’s expected props. Extra properties (upsert, remove, reset, loading) are harmlessly ignored.

const ROW_HEIGHT = 56
const cache = useTableCache('tasks', { pageSize: 50, rowHeight: ROW_HEIGHT, ... })
<VirtualTable {...cache} rowHeight={ROW_HEIGHT} overscan={10}>
...
</VirtualTable>

Extract rowHeight to a constant since it’s needed in both the cache options (for scroll correction math) and on VirtualTable (for layout).

reset() clears all cached pages and pending timers. On the next render, the cache re-creates its entry and the component re-suspends — showing your Suspense fallback while the first page is fetched fresh.

// Useful after changing sort order or filters
cache.reset()