Agent docs

0 · Data loading

How a Versable app gets data onto a screen.

How a Versable app gets data onto a screen. This is the layer under design-language/04-loading-and-states.md: that doc says what a loading screen looks like, this one says what produces it.

Audience: an agent (or person) adding or debugging data fetching.

Reference implementation: walmart-mvp/frontend/src/lib/swr.ts, 12 call sites.

§A Principles and mental models#

1. Cached read first, revalidate behind. Read the cached value synchronously during render, paint it, then refetch in the background and write back. A revisit shows content instantly rather than a skeleton. The skeleton is reserved for the genuine cache miss, which is the first-ever visit for that key.

2. Every key is scoped to the tenant, mechanically. The stored key is <activeOrgId>:<key>, built in one place so no call site can forget. Switching org does not invalidate anything: the new org simply addresses different keys, the old org's entries stay under their own namespace, and no cross-tenant read is possible by construction rather than by discipline.

The consequence that bites tests: a fixture must reset the active org between specs, or a spec inherits the previous spec's cached rows and passes for the wrong reason. Every surface doc repeats this in its section 7 for that reason.

3. A null key disables the fetch. Gated data (staff-only, or anything needing an active org) passes null as the key until the gate opens. A non-staff visit then never populates or reads that cache entry, so the gate is enforced at the data layer and not only in the render branch.

4. Any write revalidates every mounted key. One listener on a mutation event refetches all live keys. The cost model on a busy page is over-fetching, never staleness: resolving an error in a shared modal cannot leave a sibling table showing the row it just fixed. Surfaces still call their own refresh after their own mutations, for the immediate re-read.

5. Not everything should be cached, and the reason is honesty. Cache what is worth showing slightly stale. Do not cache what would mislead: live worker activity (three seconds stale reads as wrong), a per-row expansion whose data changes while the pipeline runs, a detail modal the user is actively editing. Those stay raw fetches, and blanking is the honest state. "It could be cached" is not a reason to cache it.

6. The cache can hold a payload older than the code that reads it, so every field read must survive the field being absent. Cached-read-first means the first paint after a deploy renders whatever shape was cached BEFORE it. Add a field to a payload and every returning user gets undefined for it until revalidation lands, which is long enough to see. Observed on walmart's admin overview 2026-08-09: a jobs total was added server-side, and the metric grid painted a literal NaN from formatNumber(undefined) beside five stale numbers (8,830 parts where the API said 242). Revalidation then fixed it silently, which is why this class hides: the bug is only visible in the window nobody screenshots.

The rule is not "version the cache", though you can. It is that a formatter reading cached data treats a missing field as a state, not as a number. Any deploy that changes a payload's shape is a deploy that shows the old shape once.

Take the placeholder from the app's existing absent-value vocabulary. The fail-soft character is not a free choice, and picking a fresh one makes the fix for one defect the cause of another. The walmart fix first rendered an em dash, which would have been the only em dash in a rendered string anywhere in the app and a direct breach of the standing zero-em-dash constraint, caught in review before it shipped. It became n/a, which the surrounding pages already use three times each. Grep for how absence is already spelled before spelling it again.

7. Know the layer's other sharp edges before scaling it. A small hand-rolled cache is the right size for a small app, and its gaps should be written down rather than discovered: no write primitive (optimistic updates need a local override plus a refresh), per-hook error state that is not reset when the key changes (a failed key's error paints under the next one until its fetch resolves), no in-flight dedup (dev double-invoke double-fetches), and unbounded growth (logout clears auth, not the cache).

§B Presets#

SituationPreset
List pageone key per list, refresh after that page's mutations
Per-parameter viewkey per parameter value (snapshot:<month>, spec-model:<type>)
Gated dataconditional key, null until the gate opens
Live process stateraw poll, silent failure keeps the last snapshot
Row expansion, detail modalraw fetch keyed by id, blank on open
Optimistic writelocal override, then refresh reconciles

§C Use cases through the apps#

walmart-mvp (the reference): 12 keys. home bundles four endpoints in one Promise.all; snapshot:<YYYY-MM> is the per-parameter shape; jobs-list carries a 45s poll layered on the cache; admin-overview and spec-models are staff-conditional; nav-counts is org-conditional and re-reads on route change. Deliberately uncached: Admin's 3s worker poll, the Jobs row expansion, the stage logs, and the part preview modal.

@versable-git/ui · reference, canon, and method, read in place