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" } }),
);