Combining reads
combine turns several reads into one value. It's a plain async function: ctx.get reads through the cache, and what you return is what useGet gives back.
export const loadSomething = combine(async (ctx, args: Args) => {
const a = await ctx.get(client.someRoute, ...);
return /* anything built from them */;
});
const value = useGet(loadSomething, args);
The examples below run as one app: cd examples/docs && npm run dev:combine. Its routes:
export const routes = {
getStats: builder.get(async () => success({ revenue: 1200, orders: orders.length })),
getLatestOrders: builder.get(async () => success({ orders: orders.slice(-5) })),
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 POST, because the query could be long. It only reads.
searchProducts: builder
.bodySchema(z.strictObject({ q: z.string() }))
.post(async ({ data }) =>
success({
products: products.filter((p) =>
p.toLowerCase().includes(data.body.q.toLowerCase()),
),
}),
),
getNotes: builder
.querySchema(z.strictObject({ cursor: z.coerce.number().optional() }))
.get(async ({ data }) => {
const after = data.query.cursor ?? 0;
const page = notes.filter((note) => note.id > after).slice(0, 3);
const last = page[page.length - 1];
const nextCursor = last && last.id < notes.length ? last.id : null;
return success({ notes: page, nextCursor });
}),
};
Reads that don't depend on each other
Promise.all runs them at once, so the page waits for the slowest, not for the sum:
/** Two independent reads at once: the page waits for both, not one after the other. */
export const loadDashboard = combine(async (ctx) => {
const [stats, latest] = await Promise.all([
ctx.get(client.getStats),
ctx.get(client.getLatestOrders),
]);
return { revenue: stats.revenue, orders: latest.orders };
});
function Dashboard() {
const dashboard = useGet(loadDashboard);
return (
<p>{`Revenue: ${dashboard.revenue}, latest orders: ${dashboard.orders.length}`}</p>
);
}
A read that needs another's result
The customer's id is in the order, so the second ctx.get waits for the first. A deleted customer is a normal outcome here, so it's listed with resolveAlso and comes back as a value. Anything unlisted fails the whole read and goes to the <Boundary>:
/** The customer's id is in the order, so the second read waits for the first. */
export const loadOrderWithCustomer = combine(async (ctx, id: number) => {
const { order } = await ctx.get(client.getOrder, { params: { id } });
// A deleted customer is a normal outcome here: listed, it's a value, not an error.
const customer = await ctx.get(
client.getCustomer,
{ params: { id: order.customerId } },
{ resolveAlso: ["customer-not-found"] },
);
return {
item: order.item,
customerName:
customer.result === "success" ? customer.customer.name : "Deleted customer",
};
});
function Order({ id }: { id: number }) {
const order = useGet(loadOrderWithCustomer, id);
return <p>{`${order.item}, ordered by ${order.customerName}`}</p>;
}
A POST that only reads
Reads are GET only. A search sent as a POST is still a read, so wrap it: it's cached by its args like any read. fetchCuple sends the request directly, without the cache:
/** A POST that only reads, like a long search: wrapped, it reads and caches like a GET. */
export const searchProducts = combine(async (_ctx, q: string) => {
const { products } = await fetchCuple(client.searchProducts.post, {
body: { q },
}).thenKeepSuccess();
return products;
});
function Search({ q }: { q: string }) {
const products = useGet(searchProducts, q);
return <p>{products.join(", ")}</p>;
}
Load more
The combined read loads the first count pages. The component holds count, and raises it in a transition, so the loaded notes stay on screen while the next page loads. See Pagination for page numbers and how refresh works:
/** The first `count` pages: each page's cursor comes from the page before. */
export const loadNotes = combine(async (ctx, args: { count: number }) => {
const pages = [];
let cursor: number | null | undefined;
while (cursor !== null && pages.length < args.count) {
const page = await ctx.get(client.getNotes, { query: { cursor } });
pages.push(page);
cursor = page.nextCursor;
}
return {
notes: pages.flatMap((page) => page.notes),
hasMore: cursor !== null,
};
});
function Notes() {
const [count, setCount] = useState(1);
const [isPending, startTransition] = useTransition();
const list = useGet(loadNotes, { count });
return (
<>
<ul>
{list.notes.map((note) => (
<li key={note.id}>{note.title}</li>
))}
</ul>
{list.hasMore && (
<button
type="button"
onClick={() => startTransition(() => setCount(count + 1))}
disabled={isPending}
>
{isPending ? "Loading…" : "Load more"}
</button>
)}
</>
);
}
The rules
- Create combined reads at module level. A combined read's identity is its cache identity; one created during render would never hit the cache.
- Args must be JSON (numbers, strings, plain objects and arrays): they're the cache key.
- It owns what it fetches. The calls made through
ctx.getare kept as long as the combined read is, whatever their own cache settings. So "load more" fetches one page, never all of them again. - Refreshing a route re-runs every combined read that read it.
refresh: [client.getOrder]updatesloadOrderWithCustomertoo. ctx.getshares requests with every other reader: if the list already loaded a customer, the order page doesn't fetch it again.
Combining reads is a plain async function, so there's nothing new to learn.
During the design, a combined read was going to keep state of its own, such as how many pages were loaded. But a combined read lives in the cache, so whenever the cache dropped it, that state would be lost. So state lives in React (useState, the URL) and is passed in as arguments. The cache should only make things faster, never change what you see.