@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. retrydrops 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?
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
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?
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
Returns
ReactNode
toCupleError()
toCupleError(
error,fallback):CupleError
Defined in: errors.ts:29
Makes any thrown value a CupleError.
Parameters
error
unknown
fallback
string
Returns
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?
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>.resolveAlsokeeps the listed results as values;resolveOnkeeps 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, withisStreaming: false: streams drop, and the page should stay. Callreconnect()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?
optionalerror?: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?
optionalconfig?: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 errorerrors.notify,errors.fallbackMessage: how it's shown, and the fallback textloading.blocking: while it runs, including its refresh,useIsFetching({ blocking: true })is true — for a full-page overlay
refresh?
optionalrefresh?: readonlyReadable[] | [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 theresults 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 yetpending: running, including the refresh it asked fordone: your function finished.valueis what it returned — including failures:fetchCupleresolves every server result unless you narrow it withthenKeep([...]), sodonedoes not mean "succeeded"; checkvalue.result. Resolved failures are handled: they're yours to show, typed.failed: an error nobody handled. It already went whereconfig.errors.onErrorsays: 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
readonlyid:number
Defined in: types.ts:17
For debugging only: the order combined reads were created in.
kind
readonlykind:"cuple-combined"
Defined in: types.ts:15
load
readonlyload: (context,args) =>Promise<TValue>
Defined in: types.ts:18
Parameters
context
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?
optionalcache?:object
Defined in: config.ts:28
enabled?
optionalenabled?: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?
optionalfreshMs?: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?
optionalkeepOnKeyChange?: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?
optionalmaxStoredCalls?: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?
optionalstoreStaleMs?: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?
optionalerrors?:object
Defined in: config.ts:75
fallbackMessage?
optionalfallbackMessage?: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?
optionalnotify?: (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
Returns
void
onError?
optionalonError?: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?
optionalloading?:object
Defined in: config.ts:96
blocking?
optionalblocking?:boolean
Makes useIsFetching({ blocking: true }) true: for actions while they
run, for reads while their data refetches. For a full-page overlay.
debounceMs?
optionaldebounceMs?: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?
optionaleveryMs?: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).messageis the server's, when it sent one."transport": no Cuple answer at all — offline, DNS, a proxy's error page."bug": your code threw.messageis 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.notifyshows it; the page stays.null: nowhere — it's handled. The action'sstatusis"failed"anderrorholds 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> extendsCombined<infer TArgs,any> ?TArgs:Target<R> extendsClientEndpointRef?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?
optionalconfig?: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> extendsCombined<any, infer TValue> ?TValue:Target<R> extendsClientEndpointRef?Oextendsobject?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> extendsClientEndpointRef? {resolveAlso?: readonlyListable<Target<R>>[];resolveOn?:never; } | {resolveAlso?:never;resolveOn?: readonlyListable<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 valuesresolveOn: 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>> extendsAsyncIterable<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> =Rextendsobject?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.