TanStack Query
Build TanStack Query options from your routes, with query keys derived from your routes.
The TanStack Query client is new and still settling. Its API surface may change while v2 is in beta, so pin your version if you depend on it.
@kizunajs/tanstack-query turns your api and a 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 the fetch client and your framework's package:
pnpm add @kizunajs/fetch@beta @kizunajs/tanstack-query@beta @tanstack/react-querybun add @kizunajs/fetch@beta @kizunajs/tanstack-query@beta @tanstack/react-querynpm install @kizunajs/fetch@beta @kizunajs/tanstack-query@beta @tanstack/react-querypnpm add @kizunajs/fetch@beta @kizunajs/tanstack-query@beta @tanstack/vue-querybun add @kizunajs/fetch@beta @kizunajs/tanstack-query@beta @tanstack/vue-querynpm install @kizunajs/fetch@beta @kizunajs/tanstack-query@beta @tanstack/vue-querypnpm add @kizunajs/fetch@beta @kizunajs/tanstack-query@beta @tanstack/svelte-querybun add @kizunajs/fetch@beta @kizunajs/tanstack-query@beta @tanstack/svelte-querynpm install @kizunajs/fetch@beta @kizunajs/tanstack-query@beta @tanstack/svelte-queryThe examples here use React.
Create the client
It takes the fetch client you already have:
import { KizunaTanstackQuery } from '@kizunajs/tanstack-query';
import { apiClient } from './api-client';
export const api = new KizunaTanstackQuery(apiClient);Every client method carries the route it answers, so the client is the only argument.
Run a query
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
const { data } = useQuery(
api.users.listUsers.queryOptions({
input: {
query: {
page: 1,
limit: 10,
},
},
staleTime: 60_000,
})
);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.
if (data?.status === 200) {
console.log(data.body.name);
}A route with a body or query schema also carries the automatic 400, so narrow before reading the body.
Run a mutation
import { useMutation, useQueryClient } from '@tanstack/react-query';
const queryClient = useQueryClient();
const createUser = useMutation(
api.users.createUser.mutationOptions({
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: api.users.key(),
}),
})
);
createUser.mutate({
body: {
name: 'Alice',
email: 'alice@example.com',
},
});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.
import { useInfiniteQuery } from '@tanstack/react-query';
const search = useInfiniteQuery(
api.users.searchUsers.infiniteOptions({
input: (cursor: number) => ({
query: {
q: 'alice',
limit: 10,
cursor,
},
}),
initialPageParam: 0,
getNextPageParam: (lastPage) => (lastPage.status === 200 ? lastPage.body.nextCursor : null),
})
);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 |
queryClient.invalidateQueries({
queryKey: api.users.key(),
});
queryClient.setQueryData(
api.users.getUser.queryKey({
input: {
params: {
id: '1',
},
},
}),
(old) => old
);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.
import { isUndeclaredResponseError } from '@kizunajs/tanstack-query';
const { data, error } = useQuery(
api.users.getUser.queryOptions({
input: {
params: {
id: 'usr_abc123',
},
},
})
);
if (data?.status === 404) {
return <NotFound />;
}
if (error !== null && isUndeclaredResponseError(error)) {
console.error(error.status, error.body);
}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
import { skipToken } from '@tanstack/react-query';
const { data } = useQuery(
api.users.searchUsers.queryOptions({
input:
term === ''
? skipToken
: {
query: {
q: term,
limit: 10,
cursor: 0,
},
},
})
);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.
import { HydrationBoundary, QueryClient, dehydrate } from '@tanstack/react-query';
import { serverApi } from '@/lib/server-api';
export default async function Page() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery(
serverApi.users.listUsers.queryOptions({
input: {
query: {
page: 1,
limit: 10,
},
},
})
);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UserList />
</HydrationBoundary>
);
}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.
const result = await api.users.getUser.call({
params: {
id: 'usr_abc123',
},
});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:
const { data } = useQuery(
api.assistant.reply.streamOptions({
input: {
body: {
prompt,
},
},
})
);
const text = data?.flatMap((message) => (message.event === 'delta' ? [message.data.text] : [])).join('') ?? '';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.