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#
| Situation | Preset |
|---|---|
| List page | one key per list, refresh after that page's mutations |
| Per-parameter view | key per parameter value (snapshot:<month>, spec-model:<type>) |
| Gated data | conditional key, null until the gate opens |
| Live process state | raw poll, silent failure keeps the last snapshot |
| Row expansion, detail modal | raw fetch keyed by id, blank on open |
| Optimistic write | local 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.