Skip to content

useTableCache

useTableCache manages paginated, sorted data for VirtualTable. It fetches pages on demand as the user scrolls, integrates with React Suspense for the initial load, and provides upsert / remove methods for real-time updates with surgical cache modifications and automatic scroll correction.

import { useTableCache } from '@requence/table'
function useTableCache<T>(
key: CacheKey,
options: UseTableCacheOptions<T>,
): TableCache<T>

key — A stable CacheKey (string | number | (string | number)[]) that identifies this cache. When the key changes (e.g. because a filter or sort order changed), the cache is discarded and the next render will re-suspend.

You can pass a single string or number:

const cache = useTableCache('users', { /* ... */ })

Or combine multiple dynamic parameters into an array key — the elements are joined with '-' internally:

const cache = useTableCache(['users', sortField, sortDir, filter], {
// ...
})

This is equivalent to building the string yourself, but avoids manual concatenation and makes each segment explicit:

// Same effect as the array key above
const cache = useTableCache(`users-${sortField}-${sortDir}-${filter}`, {
// ...
})
OptionTypeDefaultDescription
pageSizenumberRequired. Number of rows per page. Controls how many items are fetched per request.
rowHeightnumberRequired. Fixed height of each row in pixels. Used to calculate scroll corrections when items are added or removed above the viewport.
rowGapnumber0Gap between rows in pixels. Must match the rowGap passed to VirtualTable. Used to calculate scroll corrections — each row occupies rowHeight + rowGap pixels of scroll space.
getItemId(item: T) => stringRequired. Extracts a unique ID from an item. Used to match items during upsert and remove.
compare(a: T, b: T) => numberRequired. Comparator for sort order. Return negative if a comes before b, positive if after, 0 if equal. Used by upsert() to binary-search the correct insertion position.
fetchItems(offset: number, limit: number) => Promise<{ items: T[]; total: number }>Required. Fetches a page of data from the server. Must return the items for the requested range and the total count. See Suspense Behavior.
fetchCount() => Promise<number>Optional. Fetches just the total count. Called (debounced, 150 ms) when an upsert arrives for an unknown ID, and on every remove. See fetchCount Debouncing.
PropertyTypeDescription
refReact.RefObject<VirtualTableHandle | null>Ref to pass to VirtualTable for scroll correction via imperative handle.
rowHeightnumberThe rowHeight value passed in options. Included so {...cache} wires it up on VirtualTable automatically.
rowGapnumberThe rowGap value passed in options (or 0). Included so {...cache} wires it up on VirtualTable automatically.
totalCountnumberCurrent total count. Updated by fetch results and by upsert/remove mutations.
getItem(index: number) => T | undefinedReturns the item at the given absolute index, or undefined if the page containing that index hasn’t been fetched yet.
onRangeChange(range: { start: number; end: number }) => voidPass this directly to VirtualTable’s onRangeChange prop. Triggers page fetches for any unfetched pages within the range.
upsert(item: T) => voidInsert or update an item with surgical cache modification. See Upsert Behavior.
remove(id: string) => voidRemove an item by ID with surgical cache adjustment and immediate scroll correction. See Remove Behavior.
reset() => voidClear all cached pages. See Reset Behavior.
loadingbooleantrue when a scroll-triggered page fetch is in-flight. false during the initial Suspense-suspended fetch. Use this to show a loading indicator in the header or footer.
getTotalCount() => numberStable callback that returns the current totalCount. Useful in event handlers or effects that need the latest count without stale closure issues.

useTableCache integrates with React Suspense to provide a loading state for the initial data fetch:

  1. First fetch (no cached pages): The promise returned by fetchItems(0, pageSize) is thrown, causing the component to suspend. Wrap the table in a <Suspense> boundary to show a fallback.

  2. Subsequent fetches (at least one page cached): Page fetches triggered by scrolling are non-blocking. The loading flag becomes true, and getItem() returns undefined for indices on unfetched pages — causing Body.children to return null, which renders skeleton rows.

<Suspense fallback={<TableSkeleton />}>
<UsersTable />
</Suspense>

upsert(item) handles six cases:

  1. Item exists on a cached page — The item is updated in-place at its current position. No position change, no totalCount change.

  2. Item ID is known but on a non-cached page — The item is skipped. The cache has seen this ID in a previous fetch but the page has since been evicted or was never loaded. No action is taken because the server already has the correct data for that page.

  3. Unknown ID, sorts within a cached page — The item is inserted at the correct sorted position using compare. Subsequent contiguous pages are surgically shifted: the last item is popped from each page and unshifted onto the next, preserving page boundaries without re-fetching. totalCount is incremented by 1 immediately. If fetchCount is provided, a debounced server call corrects any accumulated drift.

  4. Unknown ID, sorts before first cached item — The item is not inserted into the cache. pendingAboveCount is incremented (the item is above the viewport). fetchCount is queried. Scroll correction is applied when the count response arrives.

  5. Unknown ID, sorts after last cached item — The item is not inserted. fetchCount is queried to update the total. No scroll correction is needed.

  6. Unknown ID, sorts between non-contiguous cached pages — The item falls in a gap and is not inserted. fetchCount is queried. If the item sorts before the visible range, pendingAboveCount is incremented for later scroll correction.

remove(id) performs surgical cache adjustment:

  1. The item’s ID is marked as removed in the knownIds tracking map. It stays there as a tombstone, so a repeated remove(id) returns immediately — upsert clears it if the item ever re-enters the result set.
  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. The last contiguous page shrinks by one.
  3. totalCount is adjusted according to what the cache knows about the ID — see below.
  4. If the item was above the viewport — either on a cached page before the visible range, or its last-known page index (from knownIds) is before the visible range — an immediate scroll correction of -(rowHeight + rowGap) is applied to prevent layout shift.
What the cache knows about the IDEffect on totalCount
Seen in a fetched page, or added by a previous upsertDecremented by 1 (clamped to 0), then reconciled by a debounced fetchCount call.
Never seenfetchCount is queried and its answer replaces the count. Without fetchCount, decremented by 1.
Already removedUnchanged — the call is a no-op.

reset() discards all cached data and resets the viewport:

  1. Any pending fetchCount timer is cleared.
  2. The scroll position is reset to the top via scrollTo(0).
  3. The internal cache iteration counter is incremented, effectively replacing the cache with a fresh, empty instance.
  4. The next render will re-suspend because no pages exist — the Suspense fallback is shown again while the first page is re-fetched.

Use reset() when the underlying data has changed in a way that can’t be expressed through upsert/remove (e.g. a bulk import, a filter change handled outside the key, or a manual refresh button).

When fetchCount is provided, the cache schedules a debounced call to fetchCount() with a 150 ms delay — when an unknown item arrives via upsert, and on every remove (to settle an unknown ID, or to correct drift after a known one). If another mutation arrives within that window, the timer is reset. This prevents a burst of real-time events from triggering many redundant count queries.

When the count response arrives, the cache:

  1. Replaces the optimistic totalCount with the authoritative server count.
  2. Computes aboveAdjustment = Math.min(pendingAboveCount, Math.max(0, countDelta)) — how many of the pending above-viewport items actually changed the count.
  3. Resets pendingAboveCount to 0.
  4. Calls scrollBy(aboveAdjustment * (rowHeight + rowGap)) on the VirtualTable handle to keep visible content stable.

If fetchCount is not provided, the cache falls back to adjusting totalCount by 1 per new or removed item. This is simpler but may drift if items are frequently inserted on pages the cache hasn’t loaded, or if removals arrive for IDs it has never seen.