The React client
@blendx/react turns a blendx API into TanStack Query options for a React app: every action, by its table and name, typed by the API itself. The code on this page comes from examples/addition/web, a page that adds two numbers, and from the adapter's tests, which run against the conformance suite's shop app.
Setting up
The page lives next to the API, as a package of its own in the same repository, because it imports the API's types. examples/addition/web depends on @blendx/react, blendx, @tanstack/react-query, react and react-dom, and is built with Vite.
One file creates the client. From src/api.ts:
import { createBlendxClient } from '@blendx/react';
import { hc } from 'blendx/client';
import type { AppType } from '../../server.ts';
import { endpoints } from '../../src/generated/client.gen.ts';
export const api = createBlendxClient(hc<AppType>('/api'), endpoints);
hc<AppType>is the typed client of the HTTP API. It carries the base URL, and whatever else every request needs, such asheaders: { authorization: ... }.endpointscomes fromsrc/generated/client.gen.ts, whichblendx generatewrites (The CLI): every action's method and path, by table and action name. It imports nothing.AppTypeis a type-only import, so no server code reaches the page's bundle.
The page provides a QueryClient, as any TanStack Query app does. From src/main.tsx:
createRoot(root).render(
<StrictMode>
<QueryClientProvider client={new QueryClient()}>
<App />
</QueryClientProvider>
</StrictMode>,
);
In development Vite serves the page and sends /api to the API, which bun server.ts serves on port 3000. From vite.config.ts:
server: {
proxy: { '/api': { target: api, rewrite: (path) => path.replace(/^\/api/, '') } },
},
Queries and mutations
An action whose method is GET gives queryOptions(input): index, show, and custom actions with method: 'get'. Every other action gives mutationOptions(). Hand them to TanStack Query's hooks. From src/App.tsx:
const { addition_results } = api;
export function App() {
const results = useQuery(addition_results.index.queryOptions());
const add = useMutation(addition_results.store.mutationOptions());
and, when the form is submitted:
add.mutate({ json: { a: numberIn(form, 'a'), b: numberIn(form, 'b') } });
- Input is what
hctakes:jsonfor a body,paramfor a member action's id,queryfor a GET action's input. An input with nothing required may be left out, as index's is above. Show needs its id:api.orders.show.queryOptions({ param: { id: '1' } }). - Data is the body of the action's success reply, typed as
hctypes it: a page for index (the example listsresults.data.data), the record for store, show, update, restore and member actions, what calculate returns for a collection action, andnullfor destroy's 204. - The options are plain objects, so they work wherever TanStack Query takes options:
useQuery,useSuspenseQuery,queryClient.fetchQuery,prefetchQuery,ensureQueryData, a router's loader. The adapter wraps none of them. - A query hands TanStack's abort signal to its request, so a query that TanStack cancels (its component unmounted, or the page called
cancelQueries) aborts its request too. The signal goes in the call'sinit, whichhcmerges into the client's owninitkey by key. So give the client noinit.signalof its own: an AbortSignal does not survive that merge. - Only the actions the blends list are on
api; any other is a type error.
Keys and invalidation
A query's key is [table, action, input], with {} for no input, and a mutation's key is [table, action]. From the tests:
expect([...api.orders.index.queryOptions().queryKey]).toEqual(['orders', 'index', {}]);
expect(apiFor().orders.store.mutationOptions().mutationKey).toEqual(['orders', 'store']);
When a mutation succeeds, it invalidates every query of its table, and it resolves once the queries on screen have refetched. That is how the example's list shows a new result without a reload. A failed mutation invalidates nothing: the server rolled its transaction back.
A reply holds only rows of its own table, so an action changes another table only through a hook, which the client cannot see. Such a mutation names the other tables, and invalidates takes only table names of the API:
const naming = api.orders.refund.mutationOptions({ invalidates: ['users'] });
The invalidation runs inside the mutation function, not in onSuccess, so the page's own options can be spread over the adapter's without losing it:
const options = {
...api.orders.refund.mutationOptions(),
onSuccess: () => {
succeeded = true;
},
};
Errors
A reply that is not a success rejects with a ProblemDetailsError, exported by @blendx/react. It has the reply's status, its problem (the Problem Details of the HTTP API) and a message, the problem's detail or else its title. A reply without Problem Details, such as a proxy's 502, gets one made from its status: { type: 'about:blank', title: 'Bad Gateway', status: 502 }. A request that never gets a reply rejects with fetch's own error.
Field errors
Every action has fieldErrors(error): the first message for each field of its input that a refusal names. From src/App.tsx:
const errors = addition_results.store.fieldErrors(add.error);
and, in the form, each field shows its message:
<Field name="a" error={errors.a} />
<Field name="b" error={errors.b} />
- A body field is named by its path with dots:
total, ortags.0for the first tag. A query parameter keeps its name. - It reads the errors of any refusal: a 422 names the fields that are invalid, and a 409 names the unique column whose value is taken, such as
email. - The keys are typed by the action's input, so
errors.reasnis a type error. - A field gets one message, the first.
error.problem.errorshas them all. - Anything else, such as a 403, a network error or no error at all, gives
{}.
The example reads each field with Number.parseFloat, so an empty field is NaN, which JSON sends as null. The API refuses it, and its message appears next to the field.
Testing
The options run without React: a QueryClient runs them the way the hooks do. From the adapter's tests, where apiFor builds the client over server.request, as Testing does for hc:
const options = apiFor().orders.quote.queryOptions({ query: { quantity: '2' } });
expect(await queryClient().fetchQuery(options)).toEqual({ total: '19.00' });
const refund = new MutationObserver(queryClient(), apiFor(1).orders.refund.mutationOptions());
const order = await refund.mutate({ param: { id: '1' }, json: { reason: 'damaged' } });
The page itself is tested in a browser. examples/addition/web/e2e runs Playwright in Chromium against the app's routes on in-memory PGlite, so every run starts from an empty table: bun run e2e in web/, after bun run e2e:install once for the browser.