React Query
@contract-kit/react-query creates typed TanStack Query options from your contracts. Queries, mutations, query keys, cancellation, and prefetching all stay tied to the same contract types as the server and client.
bun add @contract-kit/react-query @tanstack/react-query
Setup
import { createRQ } from "@contract-kit/react-query";
import { createNextClient } from "@contract-kit/next";
const client = createNextClient();
export const rq = createRQ(client);
Queries
Use rq(contract).queryOptions() with useQuery.
import { useQuery } from "@tanstack/react-query";
import { getTodo } from "@/app/contracts/todo";
import { rq } from "@/app/lib/rq";
function TodoDetail({ id }: { id: string }) {
const { data, isLoading, error } = useQuery(
rq(getTodo).queryOptions({ path: { id } }),
);
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <p>{data.title}</p>;
}
React Query passes its AbortSignal through the generated query function, so cancellation works automatically.
Mutations
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createTodo, listTodos } from "@/app/contracts/todo";
import { rq } from "@/app/lib/rq";
function CreateTodoButton() {
const queryClient = useQueryClient();
const mutation = useMutation(
rq(createTodo).mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: rq(listTodos).key() });
},
onError: (error) => {
if (error.hasStatus(422)) {
console.log("Validation failed:", error.details);
} else {
console.log("Request failed:", error.body ?? error.message);
}
},
}),
);
return (
<button onClick={() => mutation.mutate({ body: { title: "New todo" } })}>
Add Todo
</button>
);
}
The integration uses the client's throwing call() path because TanStack Query already models failed requests through its error channel. Use client safeCall() outside React Query when explicit result handling reads better.
Query keys
rq(contract).key() generates stable, contract-aware query keys for cache operations.
queryClient.invalidateQueries({ queryKey: rq(getTodo).key() });
queryClient.invalidateQueries({
queryKey: rq(getTodo).key({ path: { id: "123" } }),
});
Infinite queries
For paginated data, use infiniteQueryOptions.
import { useInfiniteQuery } from "@tanstack/react-query";
import { listTodos } from "@/app/contracts/todo";
import { rq } from "@/app/lib/rq";
const { data, fetchNextPage } = useInfiniteQuery(
rq(listTodos).infiniteQueryOptions({
query: { limit: 10 },
initialPageParam: 0,
page: ({ pageParam = 0 }) => ({
query: { offset: pageParam },
}),
getNextPageParam: (lastPage) =>
lastPage.offset + lastPage.todos.length >= lastPage.total
? undefined
: lastPage.offset + lastPage.todos.length,
}),
);
Prefetching
const queryClient = new QueryClient();
await queryClient.prefetchQuery(
rq(getTodo).queryOptions({ path: { id: "123" } }),
);