React Query

@beignet/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 @beignet/react-query @tanstack/react-query

Setup

import { createReactQuery } from "@beignet/react-query";
import { createNextClient } from "@beignet/next";

const client = createNextClient();

export const rq = createReactQuery(client);

Bind the contract builder directly with rq(contract). The exposed helper.endpoint property is there for endpoint-specific narrowing and advanced client access; normal query and mutation code should use queryOptions(), mutationOptions(), and key().

Queries

Use rq(contract).queryOptions() with useQuery.

import { useQuery } from "@tanstack/react-query";
import { getTodo } from "@/features/todos/contracts";
import { rq } from "@/client/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 "@/features/todos/contracts";
import { rq } from "@/client/rq";

function CreateTodoButton() {
  const queryClient = useQueryClient();

  const mutation = useMutation(
    rq(createTodo).mutationOptions({
      onSuccess: () => {
        queryClient.invalidateQueries({
          queryKey: rq(listTodos).contractKey(),
        });
      },
      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.

Errors are typed from the endpoint contract. Declare business failures with .errors(...) when you want stable catalog code narrowing, and keep the helper around when you want endpoint-specific narrowing:

const todo = rq(getTodo);
const { error } = useQuery(todo.queryOptions({ path: { id: "123" } }));

if (todo.endpoint.isError(error, { code: "TODO_NOT_FOUND" })) {
  console.log(error.details);
}

Query keys

rq(contract) generates stable, contract-aware query keys for cache operations. Contracts created from createContractGroup().namespace("todos") include that namespace in the key so normal TanStack Query prefix invalidation can target a whole resource.

queryClient.invalidateQueries({ queryKey: rq(getTodo).namespaceKey() });
queryClient.invalidateQueries({ queryKey: rq(getTodo).contractKey() });

queryClient.invalidateQueries({
  queryKey: rq(getTodo).key({ path: { id: "123" } }),
});

The default key shapes are:

rq(getTodo).namespaceKey(); // ["beignet", "todos"]
rq(getTodo).contractKey(); // ["beignet", "todos", "getTodo"]
rq(getTodo).key({ path: { id: "123" } });
// ["beignet", "todos", "getTodo", { path: { id: "123" } }]

queryOptions(...) uses the same required args as the base client call. If the contract requires path params, query params, or a body, the React Query options require them too. The generated key includes path, query, and body inputs, and omits null or undefined object entries so cache keys match URL serialization.

Infinite queries

For paginated data, use infiniteQueryOptions.

import { useInfiniteQuery } from "@tanstack/react-query";
import { listTodos } from "@/features/todos/contracts";
import { rq } from "@/client/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" } }),
);