Skip to main content

Reading

useGet​

function TodoList() {
const { todos } = useGet(client.getTodos);

return (
<>
<NewTodo />
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
<ClearCompleted />
</>
);
}

In this example:

  • todos is there, typed, with no loading or error checks. Until it arrives, the component suspends and the nearest <Boundary> shows its fallback.
  • Anything but success throws to the nearest <Boundary>. For a list that should always render, a failure means something is broken.
  • Every component that reads the same call shares one request and one cached result, so NewTodo and TodoItem don't need anything passed down.

Routes and endpoints​

Reads are GET only: a read runs on every render, refresh and poll, so a write endpoint (client.createTodo.post) is refused, by the types and at runtime. For a POST that only reads, like a search, wrap it: combine(async (_ctx, q: string) => fetchCuple(client.search.post, { body: { q } })).

A route stands for its GET endpoint: useGet(client.getNote, ...) is useGet(client.getNote.get, ...), and both read the same cache entry. An entry is one endpoint with one set of arguments, under the client's key. The short form works wherever something is read or refreshed: useGet, ctx.get inside combine, refresh lists, store.refresh and store.preload.

  • NOTE: A route that is itself named like an HTTP method (client.get) needs .get written out: at runtime, that's the only way to tell a route from an endpoint.

Expected failures​

When a failure is a normal outcome, list it with resolveAlso. It becomes a typed value, and everything else still throws:

function NoteDetail({ id, onClose }: { id: number; onClose: () => void }) {
const note = useGet(
client.getNote,
{ params: { id } },
{ resolveAlso: ["note-not-found", "not-your-note"] },
);

switch (note.result) {
case "note-not-found":
return (
<p className="muted">
This note was deleted.{" "}
<button type="button" onClick={onClose}>
Close
</button>
</p>
);
case "not-your-note":
return <p className="muted">This note belongs to another account.</p>;
case "success":
return (
<article>
<p>{note.note.text}</p>
<p className="muted">
<time dateTime={note.note.createdAt}>
{new Date(note.note.createdAt).toLocaleString()}
</time>
</p>
</article>
);
}
}
  • "transport-error" can be listed too, for a read that should render something when the server can't be reached. See Errors.
  • NOTE: resolveOn: [...] is the complete list instead: "success" is not implied.
  • These are the same idea as fetchCuple(...).thenKeep([...]): an option on a hook, a method on a request. The difference is the default: a read is success-only, a request resolves every result like fetch.

When the arguments change​

A component reads useGet(client.getNote, { params: { id } }), and id changes: the user opened another note. What should it show while the new note loads?

By default, the nearest <Boundary> shows its fallback again. The old note's text under the new note's title would be wrong, and a click on "Delete" in that moment would delete the note the user is no longer looking at.

That's the wrong trade for a search box: every keystroke would blank the results and flash a skeleton, while the previous results are still useful for a moment. There, keep them on screen with React's useDeferredValue, and dim them so it's clear they're about to change:

function SearchResults({ query }: { query: string }) {
// Lags behind `query` until the results for the new query are ready.
const deferred = useDeferredValue(query);
const { results } = useGet(client.search, { query: { q: deferred } });
return (
<ul className={deferred !== query ? "stale" : undefined}>
{results.map((result) => (
<li key={result.id}>{result.title}</li>
))}
</ul>
);
}

That still sends a request per keystroke. To send one once typing pauses, set config.loading.debounceMs: new args wait until they've stayed the same that long, then load as a transition, so the old results stay on screen meanwhile, like useDeferredValue. The first args load at once. With cache.enabled: false, searches nobody shows anymore aren't kept. useGetWrapped is useGet with the data wrapped in an object, next to isPending: newer args are waiting or loading, so the results can be dimmed:

/** Typing doesn't wait for the server; the results follow once it stops. */
export function App() {
const [q, setQ] = useState("");
return (
<CupleProvider store={store}>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search" />
<Boundary fallback={<p>Searching…</p>}>{q && <Results q={q} />}</Boundary>
</CupleProvider>
);
}

function Results({ q }: { q: string }) {
const found = useGetWrapped(
client.searchProducts,
{ query: { q } },
// One request once typing pauses; nothing kept for searches nobody shows.
{ config: { loading: { debounceMs: 300 }, cache: { enabled: false } } },
);
// isPending: what's shown is for an earlier q, until the new results land.
return (
<ul className={found.isPending ? "stale" : undefined}>
{found.data.products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}

When the user types on while a search is loading, or closes the results, the request nobody waits for anymore is aborted: on HTTP/1.1 a browser opens only about 6 connections per host, and a stale search shouldn't hold one. Only a first load is aborted, and only when no other component reads it and no preload or combined read asked for it. The server finishes the handler regardless, unless it listens for the request closing.

To run it: cd examples/docs && npm run dev:search.

Several fetches, one value​

An order page shows the order and the customer who placed it. The customer's id is in the order, so the second request can only start when the first one is done:

export const routes = {
getOrders: builder.get(async () => success({ orders })),

getOrder: builder
.paramsSchema(z.strictObject({ id: z.number() }))
.get(async ({ data }) => {
const order = orders.find((order) => order.id === data.params.id);
if (!order)
return notFound({ result: "order-not-found", message: "No such order." });
return success({ order });
}),

getCustomer: builder
.paramsSchema(z.strictObject({ id: z.number() }))
.get(async ({ data }) => {
const customer = customers.find((customer) => customer.id === data.params.id);
if (!customer)
return notFound({ result: "customer-not-found", message: "No such customer." });
return success({ customer });
}),
};

A combine is that chain, written as a plain async function:

/** An order and the customer who placed it. The customer's id is in the order. */
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 };
});
function OrderDetails({ id }: { id: number }) {
const { order, customer } = useGet(loadOrderWithCustomer, id);
return (
<article>
<h2>{order.item}</h2>
<p>
Ordered by {customer.name} ({customer.email})
</p>
</article>
);
}

The order list shows each order's customer with a plain useGet:

function OrderList({ onOpen }: { onOpen: (id: number) => void }) {
const { orders } = useGet(client.getOrders);
return (
<ul>
{orders.map((order) => (
<li key={order.id}>
<button type="button" onClick={() => onOpen(order.id)}>
{order.item}
</button>{" "}
<Boundary fallback={<span className="muted">…</span>}>
<CustomerName id={order.customerId} />
</Boundary>
</li>
))}
</ul>
);
}

function CustomerName({ id }: { id: number }) {
const { customer } = useGet(client.getCustomer, { params: { id } });
return <span className="muted">{customer.name}</span>;
}

In this example:

  • ctx.get reads through the same cache as useGet. The list already loaded each customer, so opening an order sends one request, getOrder, and Ada is fetched once even though she has two orders.
  • OrderDetails suspends once, until both are there. Use await for a fetch that needs another's result, and Promise.all for independent ones.
  • One failed ctx.get fails the whole read, and the <Boundary> shows it. To go on without it, list the result: ctx.get(client.getCustomer, args, { resolveAlso: ["customer-not-found"] }).
  • Refreshing client.getOrder or client.getCustomer re-runs loadOrderWithCustomer.
  • NOTE: Create combined reads at module level: a combined read's identity is its cache key. Its arguments must be JSON (numbers, strings, plain objects and arrays), because they are part of the key too.

To run it: cd examples/docs && npm run dev:orders.

Polling​

const { count } = useGet(client.getStats, undefined, { config: { loading: { everyMs: 30_000 } } });

It polls only while something reads it. When several readers poll the same data at different intervals, the shortest wins.

Why it works this way

useGet gives you the data and throws anything else, to keep the simple case simple: a product list just renders a list, and if it fails, the boundary shows it. A failure you want to handle is listed with resolveAlso, and comes back typed without manual annotation.

The cache key is built from what you pass: the endpoint, the client's key and the arguments. Hand-written keys were too complicated.

Only GET routes can be read. A read can run again at any time (refreshes, polls, retries), and a write repeated by a refresh would create a second record.