Skip to main content

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. true keeps 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.
  • retry drops 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.blocking works 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.