TanStack Query
Build TanStack Query options from your routes, with query keys derived from your routes.
@kizunajs/tanstack-query turns a generated fetch client into TanStack Query options. Query keys come from each route's own path, so invalidation is a method call.
It builds on @tanstack/query-core, the package every TanStack Query adapter shares, and returns options objects. You pass them to your own framework's hooks.
Install it alongside your framework's package. It reads everything it needs off the generated client, so your app installs nothing else from kizuna:
The examples here use React.
Create the client
It takes the client createClient returns from the file fetchClient generates:
Every method on the generated client carries its HTTP method and the statuses it answers, so the client is the only argument.
Run a query
input is what you would pass the fetch client. Everything beside it is TanStack's own and passes through untouched, so staleTime, retry, select, enabled, initialData, and placeholderData behave as documented.
data is the route's response union, discriminated on status.
A route with a body or query schema also carries the automatic 400, so narrow before reading the body.
Run a mutation
mutate takes the route's call arguments, or nothing when every argument is optional.
Infinite queries
input is a function of the page parameter. Annotate that parameter, since it types initialPageParam and getNextPageParam.
Keys and invalidation
Keys are [segments, { input, type }], where the segments are the route's path through your routes. A group's key is a prefix of every key beneath it.
| Factory | Returns | Use it for |
|---|---|---|
key() | [segments] | Invalidating a whole group or route |
queryKey({ input }) | [segments, { input, type: 'query' }] | getQueryData, setQueryData |
infiniteKey({ input }) | [segments, { input, type: 'infinite' }] | The route's infinite query |
mutationKey() | [segments] | useMutationState, isMutating |
fetchOptions is stripped from the key, because the AbortSignal it carries changes per attempt. The signal is forwarded to the client for you, and one you set yourself is left alone.
Error handling
A status the route declares arrives as data. Anything else throws UndeclaredResponseError, so retry, throwOnError, and error boundaries work.
Declaring a status changes this: a route declaring 500 has said a 500 is an outcome, so it arrives as data and is not retried. Network failures reject on their own.
Disabling a query
Server rendering
Prefetch with a client built for the request, then dehydrate. Keys are derived the same way on both sides, so the cache hydrates.
Responses are plain JSON, so the default dehydrate and hydrate need no custom serializer.
serverApi is built from a client whose onRequest forwards the incoming cookies. Client components use the browser one.
Calling a route directly
call runs a route through the client without touching the cache.
Streams
A route whose response streams offers streamOptions in place of queryOptions. It builds on TanStack's experimental_streamedQuery, so data is the list of messages received so far and grows as they arrive:
refetchMode decides what a refetch does with the messages already held: 'reset' clears them first and is the default, 'append' adds to them, and 'replace' swaps them in once the stream ends. A status other than the streamed one, the route's 400 say, rejects the query with NonStreamResponseError. The key is streamKey({ input }), typed 'stream', and there is no queryOptions or infiniteOptions, since a cache entry holding an open connection is wrong.