Data Caching
Why a dedicated cache
Section titled “Why a dedicated cache”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.
Cache persistence across Suspense
Section titled “Cache persistence across Suspense”The cache is keyed by the key string you pass to the hook:
const cache = useTableCache('users', { ... })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.
First fetch vs subsequent fetches
Section titled “First fetch vs subsequent fetches”useTableCache treats the first fetch differently from all later ones:
| Scenario | Behavior |
|---|---|
| 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.
Page-based fetching
Section titled “Page-based fetching”Pages are fetched on demand as the user scrolls. The flow works like this:
VirtualTablefiresonRangeChange({ start, end })with the visible row indices.- You pass this to
cache.handleRangeChange. - The cache calculates which page indices cover the range (
Math.floor(start / pageSize)throughMath.floor(end / pageSize)). - For each uncached, non-inflight page, it calls
fetchItems(offset, limit). - When the fetch resolves, the page is stored and a re-render is triggered.
<VirtualTable totalCount={cache.totalCount} rowHeight={40} onRangeChange={cache.handleRangeChange}>Pages that have already been fetched are not re-fetched. Duplicate requests for the same page are automatically prevented.
Sort-aware upsert
Section titled “Sort-aware upsert”upsert() handles three cases depending on whether the item already exists:
1. Exists on a cached page → update in-place
Section titled “1. Exists 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. ID seen before but not currently loaded → skip
Section titled “2. ID seen before but not currently loaded → 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 → insert at sorted position
Section titled “3. Unknown ID → insert at sorted position”This is a genuinely new item. The cache finds the correct insertion position using the compare function and inserts the item. Pages after the insertion point are invalidated because their indices have shifted.
After insertion:
- If
fetchCountis provided, a debounced server count fetch is triggered to get the authoritative total. - If
fetchCountis not provided,totalCountis incremented optimistically (which may drift if items are being added to non-loaded pages).
ID tracking
Section titled “ID tracking”The cache remembers every item ID it has ever seen across all fetched pages. This lets it distinguish between:
| Scenario | Action |
|---|---|
| Item on a currently loaded page | Update in-place |
| Item seen before, page no longer loaded | Skip (no count change) |
| Genuinely new item | Insert + count adjustment |
Without this tracking, the cache would have to assume every ID not found on loaded pages is new, causing totalCount to drift.
Debounced fetchCount
Section titled “Debounced fetchCount”When fetchCount is provided in the options, the cache calls it (debounced at 150ms) after inserting a new item. This replaces the optimistic totalCount += 1 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 150ms window executes.
If fetchCount is not provided, the cache falls back to totalCount += 1 on insert and totalCount -= 1 on remove.
Remove with page invalidation
Section titled “Remove with page invalidation”remove(id) deletes an item from the cache:
- The item’s ID is removed from tracking.
- All loaded pages are searched for the item. If found, it is removed.
totalCountis decremented by 1.- All pages after the removal point are invalidated (deleted from the cache). Their indices have shifted, so they’ll be re-fetched when scrolled into view.
cache.remove('user-123')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 filterscache.reset()