Skip to main content

@cuple/react

Reads, actions and a cache for React 19, built on fetchCuple.

Functions​

Boundary()​

Boundary(props): ReactNode

Defined in: boundary.tsx:30

Loading and error UI for everything inside it: a <Suspense> and an error boundary in one.

<Boundary fallback={<Spinner />} error={(error, retry) => <Failed onRetry={retry} />}>
<Orders />
</Boundary>
  • Without fallback, loading goes to the boundary above.
  • Without error, errors go to the boundary above.
  • retry drops what failed and renders again, which fetches it fresh.

Place boundaries where the layout wants loading and error UI: a failing widget should not take the page with it.

Parameters​

props​
children?​

ReactNode

config?​

CupleConfig

Settings for everything inside, over the provider's (or an outer Boundary's), one setting at a time. Requests inside can still override them.

error?​

"notify" | ((error, retry) => ReactNode)

What to show instead of the children when something inside fails. It gets a CupleError: error.message is always readable (the server's message, or config.errors.fallbackMessage). Or "notify": pass it to config.errors.notify, and show nothing in this region while the rest of the page stays.

fallback?​

ReactNode

Returns​

ReactNode


combine()​

combine<TArgs, TValue>(load): Combined<TArgs, TValue>

Defined in: combine.ts:24

Combines several fetches into one cached value, written as a plain async function. Only needed for composition — a single endpoint is read directly.

export const loadOrderWithCustomer = combine(async (ctx, id: number) => {
const { order } = await ctx.get(client.getOrder, { params: { id } });
const { customer } = await ctx.get(client.getCustomer, { params: { id: order.customerId } });
return { order, customer };
});

const { order, customer } = useGet(loadOrderWithCustomer, 5);

Create combined reads at module level: a combined read's identity is its cache identity, so one created during render would never hit the cache.

Args must be JSON: they are the cache key.

Type Parameters​

TArgs​

TArgs = undefined

TValue​

TValue = unknown

Parameters​

load​

(ctx, args) => Promise<TValue>

Returns​

Combined<TArgs, TValue>


createCupleStore()​

createCupleStore(): CupleStore

Defined in: store.ts:76

Makes the cache. One per app; one per test. Configure it with <CupleProvider config>.

Returns​

CupleStore


CupleProvider()​

CupleProvider(props): ReactNode

Defined in: provider.tsx:26

Makes the store available to the hooks, with the app's config. Once, at the root.

export const store = createCupleStore();

<CupleProvider
store={store}
config={{
errors: {
notify: (error) => toast(error.message),
onError: (error) => (error.kind === "transport" ? "notify" : "boundary"),
},
}}
>

Parameters​

props​
children​

ReactNode

config?​

CupleConfig

The app-wide defaults. <Boundary config> and each request's { config } override them, one setting at a time. Plain code using the store directly (store.preload, store.refreshKeys) follows this config too.

store​

CupleStore

Returns​

ReactNode


toCupleError()​

toCupleError(error, fallback): CupleError

Defined in: errors.ts:29

Makes any thrown value a CupleError.

Parameters​

error​

unknown

fallback​

string

Returns​

CupleError


useAction()​

useAction<TArgs, T>(fn, options?): Action<TArgs, T>

Defined in: use-action.ts:97

Runs async work — usually a write — with its state, error routing and refresh.

const save = useAction(
(values: FormValues) =>
fetchCuple(client.updateOrder.put, { params: { id }, body: values })
.thenKeep(["success", "invalid-body"]),
{ refresh: [client.getOrder.get, client.getOrders.get] },
);

<form onSubmit={form.handleSubmit(save.run)}>
{save.value?.result === "success" && <p>Saved</p>}
{save.value?.result === "invalid-body" && <Issues issues={save.value.issues} />}

The state is the latest run's. An earlier run that finishes later still writes and refreshes, but doesn't change what is shown. For one action per row with its own state, call useAction in the row component. When the component moves to another item, give it a key so the old result isn't shown for the new item.

Type Parameters​

TArgs​

TArgs extends unknown[]

T​

T

Parameters​

fn​

(...args) => Promise<T>

options?​

ActionOptions<T>

Returns​

Action<TArgs, T>


useGet()​

useGet<R, O>(readable, ...rest): ReadValue<R, O>

Defined in: use-get.ts:38

Reads an endpoint or a combine through the cache.

const { products } = useGet(client.getProducts.get);
const order = useGet(client.getOrder.get, { params: { id } }, { resolveAlso: ["order-not-found"] });
  • GET only: a read runs on every render, refresh and poll, so a write endpoint is refused. For a POST that only reads, use combine.
  • Suspends until the first load lands, and again when the args change: the old data belongs to other args, and showing it under the new ones could lead to a write against the wrong item. To keep old content while new args load, pass useDeferredValue(args) — or, for search boxes, config.loading.debounceMs, which does that too.
  • Success only by default: anything else throws to the nearest <Boundary>. resolveAlso keeps the listed results as values; resolveOn keeps only the listed ones.
  • Every reader of the same call shares one request and one cached result.
  • A refresh keeps the current data on screen until the new data lands.
  • Coming back to cached data (another tab, a reopened panel) shows it at once and refreshes it in the background, unless it's younger than config.cache.freshMs.

To fetch only sometimes, render the component only sometimes: {id && <Order id={id} />}. There is no flag to skip a fetch.

For more than the data — whether newer args are on their way — use useGetWrapped.

Type Parameters​

R​

R extends Readable

O​

O extends { resolveAlso?: undefined; resolveOn?: undefined; } | { resolveAlso?: readonly Listable<ClientEndpointRef & Target<R>>[]; resolveOn?: undefined; } | { resolveAlso?: undefined; resolveOn?: readonly Listable<ClientEndpointRef & Target<R>>[]; } & object = ReadOptions<R>

Parameters​

readable​

R

rest​

...[...ArgsTuple<R>[], O]

Returns​

ReadValue<R, O>


useGetWrapped()​

useGetWrapped<R, O>(readable, ...rest): WrappedRead<ReadValue<R, O>>

Defined in: use-get.ts:68

useGet, with the data wrapped in an object, next to what's known about it. Same arguments and options.

const found = useGetWrapped(client.search, { query: { q } }, {
config: { loading: { debounceMs: 300 } },
});
<ul className={found.isPending ? "stale" : undefined}>…</ul>

Type Parameters​

R​

R extends Readable

O​

O extends { resolveAlso?: undefined; resolveOn?: undefined; } | { resolveAlso?: readonly Listable<ClientEndpointRef & Target<R>>[]; resolveOn?: undefined; } | { resolveAlso?: undefined; resolveOn?: readonly Listable<ClientEndpointRef & Target<R>>[]; } & object = ReadOptions<R>

Parameters​

readable​

R

rest​

...[...ArgsTuple<R>[], O]

Returns​

WrappedRead<ReadValue<R, O>>


useIsFetching()​

useIsFetching(options?): boolean

Defined in: boundary.tsx:131

True while anything someone waits for is loading or running: a read, a refresh, an action. For a thin global progress bar. Polls and preloads don't count.

{ blocking: true }: only what asked to block — actions and refreshes with blocking: true, and refetches of reads with blocking: true. For a full-page overlay, shown for the few steps the user must wait for.

Parameters​

options?​
blocking?​

boolean

Returns​

boolean


useStream()​

useStream<TEndpoint>(endpoint, args, onEvent): object

Defined in: use-stream.ts:41

Subscribes to an SSE endpoint and hands you each event as it arrives.

useStream(client.orderEvents.get, {}, (event) => {
store.refresh([client.getOrders]); // someone else changed orders
});

The hook owns the connection: it opens it, closes it on unmount, and opens it again when the args change. Events are not stored — what to keep, or what to refresh, is yours to decide.

  • A rejected connection (the middleware answered unauthorized, say) throws to the nearest <Boundary>, like a failed read.
  • A network failure is kept as error, with isStreaming: false: streams drop, and the page should stay. Call reconnect() to try again.

To stream only sometimes, render the component only sometimes.

Type Parameters​

TEndpoint​

TEndpoint extends ClientSSEEndpointRef

Parameters​

endpoint​

TEndpoint

args​

FetchCupleSSEArgs<TEndpoint>[0]

onEvent​

(event) => void

Returns​

object

error?​

optional error?: CupleTransportError

isStreaming​

isStreaming: boolean

reconnect​

reconnect: ActionDispatch<[]>

Type Aliases​

Action​

Action<TArgs, T> = ActionState<T> & object

Defined in: use-action.ts:62

Type Declaration​

isPending​

isPending: boolean

status === "pending".

reset​

reset: () => void

Back to idle, e.g. to hide a "Saved" message.

Returns​

void

run​

run: (...args) => Promise<ActionState<T>>

Runs the function. Resolves with the resulting state — always: it never rejects, so an event handler never produces an unhandled rejection, and a form library awaiting it never hangs.

Parameters​
args​

...TArgs

Returns​

Promise<ActionState<T>>

Type Parameters​

TArgs​

TArgs extends unknown[]

T​

T


ActionOptions​

ActionOptions<T> = object

Defined in: use-action.ts:32

Type Parameters​

T​

T

Properties​

config?​

optional config?: CupleConfig

Defined in: use-action.ts:42

This action's settings, over the <Boundary>'s and the provider's. The ones actions use:

  • errors.onError: where an error nobody handled goes — "boundary" (default), "notify", null (handled), or a function choosing per error
  • errors.notify, errors.fallbackMessage: how it's shown, and the fallback text
  • loading.blocking: while it runs, including its refresh, useIsFetching({ blocking: true }) is true — for a full-page overlay
refresh?​

optional refresh?: readonly Readable[] | [ResultOf<T>] extends [never] ? never : { readonly [K in ResultOf<T>]?: readonly Readable[] }

Defined in: use-action.ts:53

What this action changes, refreshed once the function finishes. Nothing is refreshed unless it is named here.

  • [client.getTodos.get]: every time the function finishes.
  • { success: [client.getTodos.get] }: only for the listed results. The keys are the results the function can return.

Combined reads that fetched a listed endpoint through get are refreshed too.


ActionState​

ActionState<T> = { error?: undefined; status: "idle"; value?: undefined; } | { error?: undefined; status: "pending"; value?: undefined; } | { error?: undefined; status: "done"; value: T; } | { error: CupleError; status: "failed"; value?: undefined; }

Defined in: use-action.ts:23

Where an action is. Discriminated on status, so value and error only exist when they mean something:

  • idle: nothing has happened yet
  • pending: running, including the refresh it asked for
  • done: your function finished. value is what it returned — including failures: fetchCuple resolves every server result unless you narrow it with thenKeep([...]), so done does not mean "succeeded"; check value.result. Resolved failures are handled: they're yours to show, typed.
  • failed: an error nobody handled. It already went where config.errors.onError says: the nearest <Boundary> (this component is then off screen), errors.notify, or nowhere (null: this component shows it). Unless it's yours to show, don't report it again; use it only to adjust what's on screen, e.g. re-enable a button.

Type Parameters​

T​

T


CombineContext​

CombineContext = object

Defined in: types.ts:112

Handed to a combined read's function.

Methods​

get()​

get<R, O>(readable, ...rest): Promise<ReadValue<R, O>>

Defined in: types.ts:122

Reads through the cache, like useGet — shared with every other reader of the same call. Success only unless resolveOn/resolveAlso says otherwise; anything else rejects, and the combined read with it.

Reading through get is also what lets a refresh of an endpoint re-run this combined read. A plain fetchCuple inside a combined read works, but is invisible to refresh.

Type Parameters​
R​

R extends Readable

O​

O extends { resolveAlso?: never; resolveOn?: never; } | { resolveAlso?: readonly Listable<Target<R>>[]; resolveOn?: never; } | { resolveAlso?: never; resolveOn?: readonly Listable<Target<R>>[]; } = ResolveOptions<R>

Parameters​
readable​

R

rest​

...[...ArgsTuple<R>[], O]

Returns​

Promise<ReadValue<R, O>>


Combined​

Combined<TArgs, TValue> = object

Defined in: types.ts:14

Composes several fetches into one cached value. Made by combine.

Its identity is its cache identity, so create it once, at module level — never inside a component.

Type Parameters​

TArgs​

TArgs

TValue​

TValue

Properties​

id​

readonly id: number

Defined in: types.ts:17

For debugging only: the order combined reads were created in.

kind​

readonly kind: "cuple-combined"

Defined in: types.ts:15

load​

readonly load: (context, args) => Promise<TValue>

Defined in: types.ts:18

Parameters​
context​

CombineContext

args​

TArgs

Returns​

Promise<TValue>


CupleConfig​

CupleConfig = object

Defined in: config.ts:27

How Cuple behaves, grouped by concern. The same shape is accepted by <CupleProvider config>, <Boundary config> and each request's { config }. Every setting cascades on its own — request over Boundary over Provider over the built-in default — so setting cache.freshMs somewhere never resets cache.storeStaleMs from above.

Properties​

cache?​

optional cache?: object

Defined in: config.ts:28

enabled?​

optional enabled?: boolean

false: keep nothing once it's unread — data is dropped as soon as the last reader unmounts, and refetched when read again. Readers mounted at the same time still share one request. The same as freshMs: 0, storeStaleMs: 0, for search boxes and other args that keep changing. Default: true.

freshMs?​

optional freshMs?: number

How long data counts as fresh after it lands, in ms. A component that starts reading fresh data (switching back to a tab, reopening a panel) gets it from the cache as is; stale data is shown at once too, and refetched in the background. 0 always refetches, Infinity never. Fresh data nobody reads is never dropped.

Only coming back to data counts: polls, refreshes and actions' refresh refetch fresh data too. A first load is never fetched twice. Default: 0.

With Infinity, data nobody reads stays for good: right for reference data, wrong for args that keep changing (a search), which pile up.

keepOnKeyChange?​

optional keepOnKeyChange?: boolean

Whether store.refreshKeys() keeps data whose key changed (the user a with({ key }) getter returns, say). false deletes it at once, so nothing of the previous account can show again. true keeps it like any data nobody reads, so switching back is instant: only when one person owns every account. Read from the provider. Default: false.

maxStoredCalls?​

optional maxStoredCalls?: number

How many calls of one endpoint (or one combined read) nobody reads are kept, whatever freshMs and storeStaleMs say: past it, the one unread the longest is dropped. Calls something reads never count, and neither do the calls a cached combined read made. Different args are different calls: a search keeps its last few queries, not every keystroke. With several readers, the largest wins. Default: 3.

storeStaleMs?​

optional storeStaleMs?: number

How long data nobody reads stays in memory once it's stale, in ms: the clock starts when it is both stale and unread, whichever comes last. With several readers, the longest wins. Default: 5 minutes.

errors?​

optional errors?: object

Defined in: config.ts:75

fallbackMessage?​

optional fallbackMessage?: string

error.message when there's nothing readable to show: a network failure, a server result without a message, a bug in production. Default: "Something went wrong."

notify?​

optional notify?: (error) => void

How the app shows an error without replacing the page: a toast, a banner. Used by onError: "notify" and <Boundary error="notify">.

Parameters​
error​

CupleError

Returns​

void

onError?​

optional onError?: OnError

Actions: where an error nobody handled goes — "boundary", "notify", null (handled), or a function choosing per error. Errors you handle are the results your function resolves with (fetchCuple resolves every server result; narrow it with thenKeep) — typed values that never get here. Default: "boundary".

loading?​

optional loading?: object

Defined in: config.ts:96

blocking?​

optional blocking?: boolean

Makes useIsFetching({ blocking: true }) true: for actions while they run, for reads while their data refetches. For a full-page overlay.

debounceMs?​

optional debounceMs?: number

useGet only: when its args change, keep showing the current ones until they've stayed the same for this many ms, then load the new ones as a transition — the current data stays on screen meanwhile. One request for a burst of typing, not one per keystroke. The first args load at once.

A first load it moved on from (or unmounted during) is aborted, unless another component, a preload or a combined read asked for it too.

everyMs?​

optional everyMs?: number

Reads only: refetch every this many ms while something reads it.


CupleError​

CupleError = object

Defined in: errors.ts:16

Any error nobody handled, made readable. <Boundary error> and config.errors.notify receive this, whatever was thrown.

  • "response": the server answered with a result nobody listed (a 403, a 500). message is the server's, when it sent one.
  • "transport": no Cuple answer at all — offline, DNS, a proxy's error page.
  • "bug": your code threw. message is the error's own in development, and the fallback in production, so internals never reach users.

Errors you handle never get here: list them (resolveAlso, thenKeep), and they are typed values instead.

Properties​

cause​

cause: unknown

Defined in: errors.ts:25

What was actually thrown, for logging and reporting.

kind​

kind: "response" | "transport" | "bug"

Defined in: errors.ts:17

message​

message: string

Defined in: errors.ts:19

Always readable; config.errors.fallbackMessage when there's nothing better.

result​

result: string | null

Defined in: errors.ts:23

For "response": the result the server sent, e.g. "forbidden-error".

statusCode​

statusCode: number | null

Defined in: errors.ts:21

The HTTP status, when there was a response.


CupleStore​

CupleStore = object

Defined in: store.ts:31

The cache. Create one per app, pass it to <CupleProvider store={...}>, and use it directly from plain code — an SSE handler, a router loader, sign-out.

Cached data changes only when an action that names it finishes, refresh() or refreshKeys() is called, a poll fires, a component starts reading data that is no longer fresh (cache.freshMs), or an entry nobody reads expires. Nothing refetches on focus or on a timer you didn't ask for.

Methods​

clear()​

clear(): void

Defined in: store.ts:57

Drops everything. Readers suspend and fetch again. For sign-out and tests.

Returns​

void

preload()​

preload<R>(readable, ...rest): Promise<void>

Defined in: store.ts:55

Starts a fetch before anything renders it — on hover, on click, in a router loader. Don't return it from a router loader: the router would wait for it and handle its errors, instead of <Boundary>.

Resolves when it has landed. Never rejects, and a failure is not kept: nobody was waiting for it, so the next read fetches again.

Type Parameters​
R​

R extends Readable

Parameters​
readable​

R

rest​

...[...ArgsTuple<R>[], undefined]

Returns​

Promise<void>

refresh()​

refresh(targets, options?): Promise<void>

Defined in: store.ts:46

Refetches every cached call of the given endpoints and combined reads, plus every combined read that fetched one of them through get.

Calls something is reading now are refetched, and keep their old data on screen until the new data arrives. Calls nobody reads are dropped, so they are fetched fresh when next read — a deleted item nobody shows never 404s.

Resolves when the refetches have landed. Never rejects: a failure goes to whoever reads the data.

{ blocking: true } makes it count for useIsFetching({ blocking: true }), e.g. a full-page overlay, until it lands.

Parameters​
targets​

readonly Readable[]

options?​
blocking?​

boolean

Returns​

Promise<void>

refreshKeys()​

refreshKeys(): void

Defined in: store.ts:72

Checks every cached call's key again: whatever a with({ key }) getter returns may have changed (the signed-in user, a tenant, a locale).

  • Key unchanged: stays as it is. No request, no re-render.
  • Key changed: its readers read again under the new key — cached data for it shows at once, anything else loads. The old key's data is dropped, or kept like any unread data with cache.keepOnKeyChange: true.
  • Combined reads built from a changed call run again; streams reconnect.

Nothing is ever refetched under a key that is no longer current, so data of one key can't land under another. Wrap in startTransition to keep the current screen until the new data is ready.

Returns​

void


ErrorRoute​

ErrorRoute = "boundary" | "notify" | null

Defined in: config.ts:12

Where an error nobody handled goes (a result nobody listed, a network failure nobody listed as "transport-error", a bug):

  • "boundary": the nearest <Boundary> shows it.
  • "notify": errors.notify shows it; the page stays.
  • null: nowhere — it's handled. The action's status is "failed" and error holds it, for the component to show.

GetEndpoint​

GetEndpoint = ClientEndpointRef & object

Defined in: types.ts:27

An endpoint read with GET. Reads never write: a POST, PUT, PATCH or DELETE endpoint can't be read (it would run on every render, refresh and poll). For a POST that only reads — a search, a quote — use combine with fetchCuple inside.

Type Declaration​

tMethod​

tMethod: "get"


OnError​

OnError = ErrorRoute | ((error) => ErrorRoute)

Defined in: config.ts:18

One route for every error, or a function choosing per error. The function must return: null says "I handled it", so it can't be forgotten.


Readable​

Readable = GetEndpoint | Route | Combined<any, any>

Defined in: types.ts:39

Anything that can be read and cached: an endpoint, a Route, or a Combined read.


ReadableArgs​

ReadableArgs<R> = Target<R> extends Combined<infer TArgs, any> ? TArgs : Target<R> extends ClientEndpointRef ? FetchCupleArgs<Target<R>>[0] : never

Defined in: types.ts:49

The arguments a readable takes: the endpoint's input, or the combined read's args.

Type Parameters​

R​

R extends Readable


ReadOptions​

ReadOptions<R> = ResolveOptions<R> & object

Defined in: types.ts:75

Type Declaration​

config?​

optional config?: CupleConfig

This read's settings, over the <Boundary>'s and the provider's.

Type Parameters​

R​

R extends Readable


ReadValue​

ReadValue<R, O> = Target<R> extends Combined<any, infer TValue> ? TValue : Target<R> extends ClientEndpointRef ? O extends object ? Extract<Target<R>["tOutput"] | TransportErrorResult, { result: Listed<O, "resolveOn">; }> : Extract<Target<R>["tOutput"] | TransportErrorResult, { result: "success" | Listed<O, "resolveAlso">; }> : never

Defined in: types.ts:85

What a read returns, given its options.

Type Parameters​

R​

R extends Readable

O​

O = { }


ResolveOptions​

ResolveOptions<R> = Target<R> extends ClientEndpointRef ? { resolveAlso?: readonly Listable<Target<R>>[]; resolveOn?: never; } | { resolveAlso?: never; resolveOn?: readonly Listable<Target<R>>[]; } : object

Defined in: types.ts:68

How a read treats non-success results. Only one of the two may be given.

  • neither: success only; anything else throws to the nearest <Boundary>
  • resolveAlso: success, plus the listed results as values
  • resolveOn: exactly the listed results — "success" is not implied

Type Parameters​

R​

R extends Readable


Route​

Route = object

Defined in: types.ts:33

A client route, standing for its GET endpoint: client.getNotes reads as client.getNotes.get. Writing .get out works too; both are the same call.

Properties​

get​

get: GetEndpoint

Defined in: types.ts:33


StreamEvent​

StreamEvent<TEndpoint> = Extract<TEndpoint["tOutput"], AsyncIterable<unknown>> extends AsyncIterable<infer TEvent> ? TEvent : never

Defined in: use-stream.ts:14

The event type an SSE endpoint yields, taken from its server definition.

Type Parameters​

TEndpoint​

TEndpoint extends ClientSSEEndpointRef


Target​

Target<R> = R extends object ? E : R

Defined in: types.ts:42

What a readable reads: a route's GET endpoint, anything else itself.

Type Parameters​

R​

R extends Readable


WrappedRead​

WrappedRead<T> = object

Defined in: use-get.ts:46

What useGetWrapped returns: the data, and what's known about it.

Type Parameters​

T​

T

Properties​

data​

data: T

Defined in: use-get.ts:48

What useGet returns.

isPending​

isPending: boolean

Defined in: use-get.ts:54

Newer args are waiting for config.loading.debounceMs to pass, or loading: data is still the previous args'. Always false without debounceMs — new args then suspend, and the fallback shows instead.