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-querypnpm add @rest-rpc/tanstack-queryyarn add @rest-rpc/tanstack-querybun add @rest-rpc/tanstack-querynub add @rest-rpc/tanstack-queryaube add @rest-rpc/tanstack-querySetup
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.