The store
The store is the cache. Components reach it through <CupleProvider>; plain code uses it directly.
refresh
await store.refresh([client.getNotes, client.getNote]);
- Entries something is reading are refetched, and keep their old data on screen until the new data lands.
- Entries nobody reads are dropped, and fetched fresh when next read.
- It resolves when the refetches have landed, and never rejects: a failure goes to whoever reads the data.
- A network failure during a refresh keeps the old data on screen, and is retried when the browser comes back online.
preload
Start a fetch before anything renders it:
<li onMouseEnter={() => store.preload(loadOrderWithCustomer, order.id)}>
- It never rejects, and a failed preload isn't kept: nobody was waiting for it, so the next read fetches again.
- In a router loader, call it without returning it (
void store.preload(...)), so loading and errors stay with<Boundary>instead of the router. - NOTE: A read of
{ id: 5 }doesn't hit a preload of{ id: "5" }. When that happens, the store warns in the console.
Switching accounts
The cache doesn't know what your client's middleware sends, so tell it whose data a cache entry is, with a key:
let signedInAs: AccountId | null = null;
export const client = createClient<typeof routes>({ path: "/rpc" }).with({
// Whose data a call is: each account's calls are cached under its own key.
key: () => signedInAs ?? "signed-out",
middleware: () => ({
headers: { authorization: signedInAs ? ACCOUNTS[signedInAs].token : "" },
}),
});
When the account changes, call store.refreshKeys():
/** The cache. One per app; plain code (sign-in, SSE handlers) uses it directly. */
export const store = createCupleStore();
/** What each write changes, in one place. Actions name these in `refresh`. */
export const refreshes = {
notes: [client.getNotes, client.getNote],
contacts: [client.listContacts, client.getContact],
};
// Switching accounts re-reads only what depends on the account: every call's
// key changed, so each reader reads again under the new one. The previous
// account's data is dropped at once (the default `keepOnKeyChange: false`): the
// next person at this screen may not be the same person.
export function signIn(accountId: AccountId) {
signedInAs = accountId;
store.refreshKeys();
}
// Signing out drops everything: nothing of the account stays in memory.
export function signOut() {
signedInAs = null;
store.clear();
}
It checks every cache entry's key again:
- Key unchanged: stays as it is. No request, no re-render.
- Key changed: its readers read again under the new key, even memoized ones. Cached data for the new key shows at once; anything else loads.
- The old key's data is deleted at once (
cache.keepOnKeyChange: false, the default), so nothing of it can show again.truekeeps it like any unread data, so switching back is instant: use it only when one person owns every account, like work and personal inboxes. - Combined reads built from a changed call run again; streams reconnect.
Wrap it in startTransition to keep the current screen until the new data is ready. Signing out calls store.clear(): nothing of the account stays in memory.
Both exist because the right UX differs: refreshKeys() can keep the screen until the new account's data arrives, clear() empties it at once.
clear
store.clear();
Drops everything; readers suspend and fetch again. For sign-out and tests.
Boundary
<Boundary fallback={<Spinner />} error={(error, retry) => <Failed onRetry={retry} />}>
- Without
fallback, loading goes to the boundary above. - Without
error, errors go to the boundary above. retrydrops the data that failed from the cache and renders again, so it's fetched fresh. Other data is untouched.- Place boundaries where the layout wants loading and error UI: a failing widget shouldn't take the page with it.
Loading
-
First loads suspend: give
<Boundary fallback>a skeleton shaped like the content, so nothing jumps when the data lands. -
Refreshes keep the content on screen. Show background work with
useIsFetching(), e.g. a thin bar at the top:function Progress() {return useIsFetching() ? <TopBar /> : null;}It's true while any read, refresh or action runs.
-
Actions show where they act: mark the button or row with
aria-busy={action.isPending}, and style it.
Delay placeholders by about 150 ms with CSS (animation-delay), so fast responses never flash one. The example app in examples/react does all of this.
Full-page loading
Most loading should stay local: skeletons, busy buttons. For the few steps the user must wait for, opt in with loading: { blocking: true }, and cover the page only then:
/** A bulk step the user must wait for: blocks the page while it runs. */
function ImportSamples() {
const importSamples = useAction(() => fetchCuple(client.importContacts.post), {
refresh: refreshes.contacts,
config: { loading: { blocking: true }, errors: { onError: "notify" } },
});
return (
<button type="button" onClick={() => importSamples.run()}>
Import sample contacts
</button>
);
}
/**
* Covers the page while a blocking step runs: only work that asked for it with
* `blocking: true`, never ordinary loads.
*/
export function PageOverlay() {
const blocking = useIsFetching({ blocking: true });
if (!blocking) return null;
return (
<div className="overlay" role="status" aria-live="polite">
<span className="spinner" aria-hidden />
Working…
</div>
);
}
config.loading.blockingworks on actions, on reads (useGet(x, args, { config: { loading: { blocking: true } } })blocks while that data refetches), and on a whole<Boundary config>. For plain code:store.refresh(targets, { blocking: true }).- A first load shows the
<Boundary>fallback, as always, never the overlay.