TanStack Query

Build TanStack Query options from your routes, with query keys derived from your routes.

Supports
React QueryVue QuerySvelte Query

@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:

pnpm add @kizunajs/tanstack-query@beta @tanstack/react-query
pnpm add @kizunajs/tanstack-query@beta @tanstack/vue-query
pnpm add @kizunajs/tanstack-query@beta @tanstack/svelte-query

The examples here use React.

Create the client

It takes the client createClient returns from the file fetchClient generates:

src/lib/api.ts
import { KizunaTanstackQuery } from '@kizunajs/tanstack-query';
import { createClient } from './api-client.generated';

export const api = new KizunaTanstackQuery(
    createClient({
        baseUrl: 'http://localhost:3000',
    })
);

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

user-list.tsx
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.

user-list.tsx
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

create-user.tsx
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.

user-search.tsx
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.

FactoryReturnsUse 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
user-list.tsx
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.

user-profile.tsx
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

user-search.tsx
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.

app/page.tsx
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.

users.ts
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:

assistant.tsx
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.

Reference