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 { createClient } from "@beignet/core/client";
import { createReactQuery } from "@beignet/react-query";
import { QueryClient } from "@tanstack/react-query";

export const apiClient = createClient({
  validateInput: true,
});

export const rq = createReactQuery(apiClient);

export function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,
      },
    },
  });
}

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(), filter helpers such as contractFilter(), invalidate(queryClient, ...), and key() when TanStack APIs need the raw key.

Queries

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

import { useQuery } from "@tanstack/react-query";
import { getTodo } from "@/features/todos/contracts";
import { rq } from "@/client";

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";

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

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

Idempotency keys and retries

For contracts with idempotency metadata, the generated mutationFn derives one idempotency key per mutate(...) invocation and keeps it stable across TanStack retry attempts. TanStack Query re-invokes mutationFn with the same variables object on every retry, so a mutation configured with retry sends the same key on each attempt and the server replays the stored result instead of executing the command again:

const mutation = useMutation(
  rq(createTodo).mutationOptions({
    retry: 2,
  }),
);

// All three attempts (initial + 2 retries) share one idempotency key.
mutation.mutate({ body: { title: "New todo" } });

Separate mutate(...) calls get separate keys, so retry stability does not deduplicate double-clicks — disable the submit button while the mutation is pending, or pass an explicit key when two invocations should count as one logical command:

mutation.mutate({ body: { title: "New todo" }, idempotencyKey: key });

One caveat: calling mutate() with no variables skips per-invocation key derivation, and the client generates a fresh key per attempt instead. Pass a variables object (even an empty one) when an idempotent mutation should keep its key across retries.

Query keys

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

queryClient.invalidateQueries(rq(getTodo).namespaceFilter());
rq(getTodo).invalidate(queryClient);

rq(getTodo).invalidate(queryClient, { path: { id: "123" } });

The default key shapes behind those filters are:

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

Contract keys include the contract route after the local name, so two contracts with the same derived local name but different routes — two un-namespaced groups with /v1 and /v2 prefixes, for example — never share a cache key.

Use the smallest filter that matches the data you want to refresh:

Filter helperScopeUse it for
namespaceFilter()Every contract in one namespaceA resource-wide write changed list, detail, search, or count data.
contractFilter()Every call to one contractA write changed any filtered or paginated result from that contract.
filter({ path, query, body })One parameter-scoped contract keyA write changed one detail page, path group, or known filter set.

helper.invalidate(queryClient, params?, options?) wraps those filters for the common mutation path. With no params it invalidates every cached call to the contract — the usual choice after a create. With params it targets one detail key or parameter prefix — the usual choice after an update, paired with a contract-level invalidation of the list.

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.

When a route has filters, put the normalized filter values in queryOptions. The generated key then separates each filter set automatically:

const todosQuery = useQuery(
  rq(listTodos).queryOptions({
    query: {
      status,
      search,
      limit: 20,
      offset: 0,
    },
  }),
);

Headers and query keys

Headers are excluded from generated query keys by default. Keys end up in persisted caches and dehydrated server payloads, so including headers automatically would leak credentials such as Authorization tokens. When a header changes response data — a tenant or workspace header, for example — opt that specific header into keys at the adapter level:

export const rq = createReactQuery(apiClient, {
  keyHeaders: ["X-Tenant-Id"],
});

With keyHeaders set, queryOptions(...), infiniteQueryOptions(...), and key(...) include a normalized headers component built only from the whitelisted names present on the call. Names match case-insensitively and are stored lowercased:

rq(listTodos).queryOptions({
  headers: { "X-Tenant-Id": "tenant-1", Authorization: "Bearer ..." },
});
// queryKey: ["beignet", "todos", "listTodos", "GET /todos",
//   { headers: { "x-tenant-id": "tenant-1" } }]

Never whitelist credential headers. For one-off cases, the per-call key override remains the escape hatch.

Infinite queries

For paginated data, use infiniteQueryOptions.

import { useInfiniteQuery } from "@tanstack/react-query";
import { listTodos } from "@/features/todos/contracts";
import { rq } from "@/client";

const { data, fetchNextPage } = useInfiniteQuery(
  rq(listTodos).infiniteQueryOptions({
    query: { limit: 10 },
    initialPageParam: 0,
    page: ({ pageParam = 0 }) => ({
      query: { offset: pageParam },
    }),
    getNextPageParam: (lastPage) =>
      lastPage.page.hasMore
        ? lastPage.page.offset + lastPage.items.length
        : undefined,
  }),
);

For cursor pagination, keep stable filters in the generated key and put the cursor in page(...):

const todosQuery = useInfiniteQuery(
  rq(listTodos).infiniteQueryOptions({
    query: {
      status: "open",
      limit: 20,
    },
    initialPageParam: null as string | null,
    page: ({ pageParam }) => ({
      query: {
        cursor: pageParam,
      },
    }),
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  }),
);

Body-paginated contracts, such as a POST search endpoint, work the same way: keep stable filters in the static body and put the cursor in page(...). The static body is part of the generated key, and each page request sends the merged body:

const searchQuery = useInfiniteQuery(
  rq(searchTodos).infiniteQueryOptions({
    body: { term: "beignet" },
    initialPageParam: null as string | null,
    page: ({ pageParam }) => ({
      body: { cursor: pageParam },
    }),
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  }),
);

If all params are computed dynamically, pass a custom key. That makes the cache scope explicit instead of hiding an unstable key inside the helper.

Server rendering and prefetching

queryOptions(...) works anywhere TanStack Query accepts query options, including Server Component prefetching:

const queryClient = makeQueryClient();

await queryClient.prefetchQuery(
  rq(getTodo).queryOptions({ path: { id: "123" } }),
);

The hydration setup around that call — a per-request QueryClient, HydrationBoundary, and dehydrate — is standard TanStack Query; follow the TanStack Query SSR guide. The Beignet-specific point: prefetching through rq(...) makes an HTTP request back into your own app. Use it when the browser should keep owning the server-state workflow after the first render. When a Server Component only needs server-rendered data and no client cache, call the use case directly with server.createContextFromNext() instead of making an internal HTTP request.

Optimistic updates

Optimistic updates are standard TanStack Query — onMutate, cancel, snapshot, and rollback all work unchanged; follow the TanStack Query optimistic updates guide. The Beignet part is the cache key: rq(getTodo).key({ path: vars.path }) gives cancelQueries, getQueryData, and setQueryData the exact entry to touch, and invalidate(queryClient, ...) handles the onSettled refetch.