Skip to content
rest-rpc
Esc
↑↓navigate↵open⌘Jpreview
On this page

TanStack Query

Create typed TanStack Query utils and combine them with your Tanstack Query setup to call your routes over HTTP.

Install

npm install @rest-rpc/tanstack-query
pnpm add @rest-rpc/tanstack-query
yarn add @rest-rpc/tanstack-query
bun add @rest-rpc/tanstack-query
nub add @rest-rpc/tanstack-query
aube add @rest-rpc/tanstack-query

Setup

createTanstackQueryUtils() takes a contract and the same options as the Fetch Client. It returns a util tree with the same shape as the contract. The utils create options objects and keys; they do not create a QueryClient or call hooks.

import { createTanstackQueryUtils } from "@rest-rpc/tanstack-query";
import { api } from "./client-contract";

export const restrpc = createTanstackQueryUtils(api, {
	baseUrl: "https://api.example.com",
});

Queries

Use queryOptions() to create a typed TanStack Query options object for a route. The util accepts request input, Fetch options, and TanStack Query options in one object.

import { useQuery } from "@tanstack/react-query";

const todo = useQuery(
	restrpc.todos.get.queryOptions({
		request: {
			params: { id: "todo_1" },
		},
	}),
);

Infinite Queries

Use infiniteQueryOptions() when each page should be fetched with route request input. The query key is inferred from the route and does not include the request, so every page for the route shares one infinite query cache entry.

initialPageParam and the values returned by getNextPageParam describe pagination state. The request callback maps each page parameter to the route request used for its API call.

import { useInfiniteQuery } from "@tanstack/react-query";

const todos = useInfiniteQuery(
	restrpc.todos.page.infiniteQueryOptions({
		request: (cursor: string | undefined) => ({
			query: { cursor, status: "open", limit: 50 },
		}),
		initialPageParam: undefined,
		getNextPageParam: (lastPage) => lastPage.body.nextCursor,
	}),
);

Streamed Queries

Use streamedQueryOptions() for routes with one successful .streamResponse(status, schema) or a plain .streamOutput(schema). The util passes the async iterable to TanStack Query and stores the materialized stream data. It discards a response envelope when the route uses .streamResponse(); .streamOutput() clients return the iterable directly.

import { useQuery } from "@tanstack/react-query";

const events = useQuery(restrpc.todos.events.streamedQueryOptions());

By default, SSE events accumulate into an array.

events.data;
// Array<SseEvent<{ id: string; message: string }>> | undefined

Pass initialValue and reducer when you want a different accumulated data shape.

const messages = useQuery(
	restrpc.todos.events.streamedQueryOptions({
		initialValue: "",
		reducer: (text, event) => `${text}${event.data.message}`,
	}),
);

messages.data;
// string | undefined

refetchMode is passed to streamedQuery and controls how a refetch treats existing data. See Fetch Client for the event shape.

Skipping Queries

To disable a query in type-safe way you can use skipToken as request input.

import { skipToken } from "@tanstack/query-core";

const todo = useQuery(
	restrpc.todos.get.queryOptions({
		request: selectedId ? { params: { id: selectedId } } : skipToken,
	}),
);

For infinite queries, pass skipToken instead of the request callback.

const todos = useInfiniteQuery(
	restrpc.todos.page.infiniteQueryOptions({
		request: status
			? (cursor: string | undefined) => ({ query: { cursor, status } })
			: skipToken,
		initialPageParam: undefined,
		getNextPageParam: (lastPage) => lastPage.body.nextCursor,
	}),
);

Query Keys

Query keys are the route’s key path in the route tree, followed by the request input when there is one. Use queryKey() to get the key outside an options object. The returned key is typed for TanStack Query APIs.

restrpc.todos.get.queryKey({ params: { id: "todo_1" } }); // ["todos", "get", { params: { id: "todo_1" } }]
restrpc.todos.list.queryKey(); // ["todos", "list"]

Because keys share the route path as a prefix, restrpc.todos.get.queryKey() without a request matches every todos.get query when used as a filter.

const todoKey = restrpc.todos.get.queryKey({ params: { id: "todo_1" } });

const todo = queryClient.getQueryData(todoKey);

queryClient.setQueryData(todoKey, (current) =>
	current && current.status === 200
		? {
				...current,
				body: {
					...current.body,
					completed: true,
				},
			}
		: current,
);

await queryClient.invalidateQueries({
	queryKey: todoKey,
});

Custom Query Keys

Pass queryKey in options when a query needs a custom key.

const todo = useQuery(
	restrpc.todos.get.queryOptions({
		request: { params: { id: "todo_1" } },
		queryKey: ["todos", "detail", "todo_1"],
	}),
);

Use the same custom key with TanStack Query cache APIs.

await queryClient.invalidateQueries({
	queryKey: ["todos", "detail", "todo_1"],
});

Mutations

import { HttpError } from "@rest-rpc/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";

const queryClient = useQueryClient();

const createTodo = useMutation(
	restrpc.todos.create.mutationOptions({
		onSuccess: async () => {
			await queryClient.invalidateQueries({
				queryKey: restrpc.todos.list.queryKey(),
			});
		},
		onError(error) {
			if (error instanceof HttpError) {
				console.log(error.status, error.body);
			} else if (!(error instanceof Error) && error.status === 409) {
				console.log(error.body.code);
			}
		},
	}),
);
createTodo.mutate({
	body: { title: "Write docs" },
});

Mutation options use the route path as their default mutation key. Use mutationKey() with TanStack Query APIs that filter or configure mutations.

queryClient.setMutationDefaults(restrpc.todos.create.mutationKey(), {
	retry: 2,
});

Fetch Options

Generated options accept normal TanStack Query options plus fetchOptions, which accepts the Fetch client’s per-call options. Query options do not accept signal, because the cancellation signal provided by TanStack Query is passed to the request automatically.

const todo = useQuery(
	restrpc.todos.get.queryOptions({
		request: { params: { id: "todo_1" } },
		fetchOptions: {
			cache: "no-store",
			additionalHeaders: {
				"x-trace-id": traceId,
			},
		},
	}),
);

Error Model

TanStack Query uses success and error channels.

Plain outputs and declared 2xx responses get mapped to the data key.

Declared non-2xx responses get mapped to the error key.

Undeclared statuses and response validation failures become HttpError. Network and other unexpected failures become ordinary Error objects.

HttpError is re-exported from @rest-rpc/tanstack-query, so errors can be narrowed with instanceof HttpError.

Type Helpers

Use the type helpers when a route’s TanStack Query types need to be reused, for example in custom hooks or cache updates. Each helper takes a route from the contract, or a route tree to infer a matching tree of types.

import type {
	InferClientError,
	InferClientRequest,
	InferClientSuccess,
} from "@rest-rpc/tanstack-query";
import { api } from "./client-contract";

type TodoData = InferClientSuccess<typeof api.todos.get>;
type TodoError = InferClientError<typeof api.todos.get>;
type CreateTodoVariables = InferClientRequest<typeof api.todos.create>;
Type Infers
InferClientSuccess<Route> The data of a query or mutation.
InferClientError<Route> The error of a query or mutation, as described in Error Model.
InferClientRequest<Route, GlobalHeaders> The request of a query and the variables passed to mutate().
InferClientStreamData<Route> The data of each event in a streamed query.

InferClientSuccess, InferClientRequest, and InferClientStreamData are re-exported from @rest-rpc/core. InferClientError adds HttpError and Error to the core InferClientError, because the error channel also receives thrown errors. Import InferClientError from @rest-rpc/core when you need only the declared non-2xx responses.

TanstackQueryUtilsFor<typeof api> is the type of the whole util tree returned by createTanstackQueryUtils(). It accepts the type of your globalHeaders as a second argument.

Was this page helpful?