React Query

@beignet/react-query turns Beignet contracts into typed TanStack Query options. Start by displaying a list, then add a mutation that refreshes it. TanStack Query continues to own loading state, errors, retries, and caching.

The first examples use the listTodos and createTodo contracts from Quickstart. Sign in to the app before using them: the starter's todos belong to the current user. For a complete feature with forms, ownership checks, and tests, follow Build your first feature.

Setup

The full-stack starter already installs these packages, creates client/index.ts, and mounts a Query Client provider. Keep that setup when adding feature code. In an existing React app, install the integration and configure it once:

bun add @beignet/react-query @tanstack/react-query
// client/index.ts
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 },
    },
  });
}

Mount one provider around the client components that share a cache. This is the starter's Next.js provider; other React hosts use the same component at their application root:

// app/providers.tsx
"use client";

import { QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode, useState } from "react";
import { makeQueryClient } from "@/client";

export function Providers({ children }: { children: ReactNode }) {
  const [queryClient] = useState(() => makeQueryClient());

  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}

In Next.js, render <Providers>{children}</Providers> inside the root layout's <body>. Reuse the existing provider if your app already has one. The client above makes same-origin requests; use the client configuration when the API lives at another origin.

Queries

Pass rq(contract).queryOptions() to useQuery. The request parameters and response data come from the contract:

// features/todos/components/todo-list.tsx
"use client";

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

export function TodoList() {
  const todos = useQuery(rq(listTodos).queryOptions());

  if (todos.isPending) return <p>Loading todos…</p>;
  if (todos.isError) return <p role="alert">{todos.error.message}</p>;
  if (todos.data.items.length === 0) return <p>No todos yet.</p>;

  return (
    <ul>
      {todos.data.items.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

React Query passes its AbortSignal through the generated query function, so cancelling a query cancels its request. Loading and error states use TanStack's normal APIs.

Use select when the component needs a smaller view of the response. The cache still stores the complete contract response:

const todos = useQuery(
  rq(listTodos).queryOptions({ select: (page) => page.items }),
);
// todos.data: Todo[] | undefined

Mutations

Pass mutationOptions() to useMutation and use invalidates to name the queries affected by a successful write:

// features/todos/components/create-todo-button.tsx
"use client";

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

export function CreateTodoButton() {
  const create = useMutation(
    rq(createTodo).mutationOptions({
      invalidates: () => [rq(listTodos).contractFilter()],
    }),
  );

  return (
    <div>
      <button
        type="button"
        disabled={create.isPending}
        onClick={() => create.mutate({ body: { title: "New todo" } })}
      >
        {create.isPending ? "Adding…" : "Add todo"}
      </button>
      {create.isError && <p role="alert">{create.error.message}</p>}
    </div>
  );
}

Render CreateTodoButton beside TodoList under the same provider. After a successful create, all cached listTodos queries become stale and active queries refetch. The button stays pending until that invalidation work settles; mutateAsync() waits for it too. A failed write displays an error and does not run invalidates.

Pass lifecycle callbacks inside mutationOptions({ ... }). Replacing onSuccess after spreading the returned options replaces the invalidation behavior. The invalidation reference explains callback ordering and post-success failures.

The integration uses the client's throwing call() path because TanStack Query already models failures through its error state. Use client safeCall() outside React Query when explicit result handling reads better. Errors retain the contract's catalog codes; see typed mutation errors for narrowing them.

Declarative invalidation requires TanStack React Query 5.102.0 or newer in the v5 line. When enabling mutation retries, read Idempotency keys and retries so a retried write keeps the same identity.

Feature client helpers

When components share a query or mutation, move its options into features/<feature>/client/. Keep the query and its invalidation rules together:

The starter already has this module. Keep its update, delete, and other helpers when updating the list and create options shown here.

// features/todos/client/queries.ts (excerpt)
import { rq } from "@/client";
import { createTodo, listTodos } from "@/features/todos/contracts";

export function listTodosQueryOptions() {
  return rq(listTodos).queryOptions();
}

export function createTodoMutationOptions() {
  return rq(createTodo).mutationOptions({
    invalidates: () => [rq(listTodos).contractFilter()],
  });
}

The components then call useQuery(listTodosQueryOptions()) and useMutation(createTodoMutationOptions()). Their loading, error, and rendering code stays the same, and each use of the mutation gets the same invalidation rules. For a query used in only one component, calling rq(contract) there is also fine.

Clear the Query Client when the authenticated identity changes. The session cookie is not part of a generated query key, so a new account must not inherit the previous account's cached responses. The projects tutorial shows cancellation and cache clearing at sign-out.

Infinite queries

For paginated data, use infiniteQueryOptions. Contracts that follow the framework cursor convention — an optional cursor query param and a PageResult response with page.nextCursor — spread cursorPagination() into the options. The listProjects contract from Build your first feature uses this convention:

import { useInfiniteQuery } from "@tanstack/react-query";
import { cursorPagination } from "@beignet/react-query";
import { listProjects } from "@/features/projects/contracts";
import { rq } from "@/client";

const { data, fetchNextPage, hasNextPage } = useInfiniteQuery(
  rq(listProjects).infiniteQueryOptions({
    query: { limit: 20 },
    ...cursorPagination(),
  }),
);

The stable filters stay in the generated cache key, the first page is fetched without a cursor, each next page sends lastPage.page.nextCursor, and null stops fetching. data.pages is typed per page because the options observe InfiniteData of the contract response. Type safety is structural: contracts without a cursor query param or without page.nextCursor in the response fail to typecheck at the spread site.

Contracts that paginate differently pass initialPageParam, getNextPageParam, and page(...) by hand. The following offset example assumes your listTodos contract declares limit and offset query parameters and an offset page response. The starter returns an offset page but does not expose those query parameters; add them to the contract before using this pattern:

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

Body-paginated contracts, such as a POST search endpoint, 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. This example assumes an app-defined searchTodos contract with term and cursor body fields and a cursor page response:

const searchQuery = useInfiniteQuery(
  rq(searchTodos).infiniteQueryOptions({
    body: { term: "beignet" },
    initialPageParam: null as string | null,
    page: ({ pageParam }) => ({
      body: { cursor: pageParam },
    }),
    getNextPageParam: (lastPage) => lastPage.page.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

For an ordinary { contract, useCase } route, prefetch in a Next.js Server Component by calling the use case in process. Keep the contract-derived query key so the client reads the prefetched data from the same cache entry. Use this path when the use case output is the contract success body; it does not run HTTP hooks or handler-specific response mapping, so authorization belongs in the use case.

The hydration setup — a per-request QueryClient, HydrationBoundary, and dehydrate — is standard TanStack Query; follow the TanStack Query SSR guide.

First define the cached request-context helper once:

// lib/server-context.ts
import "@beignet/core/server-only";

import { cache } from "react";
import { getServer } from "@/server";

export const getAppRequestContext = cache(async () => {
  const server = await getServer();
  return server.createContextFromNext();
});

Layouts and Server Components may use that context directly for request metadata, ctx.auth, and ctx.tenant. Add a server-only React Query helper that replaces the HTTP query function with the use case call:

// lib/server-react-query.ts
import "@beignet/core/server-only";

import type { QueryFunction, QueryKey } from "@tanstack/react-query";
import type { AppContext } from "@/app-context";

type UseCase<TInput, TOutput> = {
  run(args: { ctx: AppContext; input: TInput }): Promise<TOutput>;
};

type QueryOptionsLike = {
  queryKey: QueryKey;
  queryFn: QueryFunction<unknown, QueryKey>;
};

type QueryOptionsOutput<TOptions extends QueryOptionsLike> = Awaited<
  ReturnType<TOptions["queryFn"]>
>;

export function serverUseCaseQueryOptions<
  TInput,
  TOptions extends QueryOptionsLike,
>(
  options: TOptions,
  useCase: UseCase<TInput, QueryOptionsOutput<TOptions>>,
  ctx: AppContext,
  input: TInput,
): Omit<TOptions, "queryFn"> & {
  queryFn: QueryFunction<QueryOptionsOutput<TOptions>, QueryKey>;
} {
  return {
    ...options,
    queryFn: () => useCase.run({ ctx, input }),
  };
}
import { makeQueryClient, rq } from "@/client";
import { listTodos } from "@/features/todos/contracts";
import { listTodosUseCase } from "@/features/todos/use-cases";
import { getAppRequestContext } from "@/lib/server-context";
import { serverUseCaseQueryOptions } from "@/lib/server-react-query";

const ctx = await getAppRequestContext();
const queryClient = makeQueryClient();

await queryClient.prefetchQuery(
  serverUseCaseQueryOptions(
    rq(listTodos).queryOptions(),
    listTodosUseCase,
    ctx,
    {},
  ),
);

When a Server Component only needs server-rendered data and no hydrated client cache, call the use case directly with getAppRequestContext() and skip React Query.

Prefetch over HTTP

Use the generated HTTP query function when prefetching must run HTTP hooks or handler-specific response mapping. Create the client per request with a trusted, server-configured API origin and the current user's credentials. A server-side fetch does not inherit the browser's session cookie:

import { createClient } from "@beignet/core/client";
import { createReactQuery } from "@beignet/react-query";
import { headers } from "next/headers";
import { makeQueryClient } from "@/client";
import { listTodos } from "@/features/todos/contracts";

const requestHeaders = await headers();
const serverRq = createReactQuery(
  createClient({
    baseUrl: "https://api.example.com", // Replace with your configured API origin.
    headers: () => ({ cookie: requestHeaders.get("cookie") ?? "" }),
    validateInput: true,
  }),
);
const queryClient = makeQueryClient();

await queryClient.prefetchQuery(serverRq(listTodos).queryOptions());

Keep this code inside the Server Component's request execution, and use the same keyHeaders configuration as the browser client if you have customized it.

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.

Use these helpers for explicit cache operations, such as responding to a realtime change hint. For successful mutations, return the same filters from invalidates. The detail examples below assume your app defines a getTodo contract for GET /api/todos/:id in the todos namespace; the starter has list, create, update, and delete contracts but no detail query.

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 /api/todos/:id"]
rq(getTodo).key({ path: { id: "123" } });
// ["beignet", "todos", "getTodo", "GET /api/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 explicit invalidation. With no params it invalidates every cached call to the contract. With params it targets a detail key or parameter prefix. Await it when subsequent work depends on the refetch settling.

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 path and query entries so cache keys match URL serialization. Request bodies keep null values because JSON distinguishes an explicit null from an omitted property. Body keys follow JSON serialization, which omits undefined object properties while preserving explicit nulls and empty objects.

When a contract declares filters, put the normalized filter values in queryOptions. The generated key then separates each filter set automatically. This example assumes your list contract declares status, search, limit, and offset query parameters:

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 preview or locale header, for example — opt that specific header into keys at the adapter level:

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

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-Preview-Mode": "draft", Authorization: "Bearer ..." },
});
// queryKey: ["beignet", "todos", "listTodos", "GET /api/todos",
//   { headers: { "x-preview-mode": "draft" } }]

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

Typed cache access

Use cacheEntries(queryClient, match?) to inspect cached standard queries with contract-derived response and parameter types:

const todos = rq(listTodos);
const entries = todos.cacheEntries(queryClient);

for (const { queryKey, params, data } of entries) {
  // data is the raw listTodos response, or undefined before data is available.
  console.log(queryKey, params, data?.items);
}

match uses the same parameter-prefix matching as filter(...). Path, query, body, and header objects accept a subset of their top-level fields. For a contract with both workspaceId and id path parameters, pass { path: { workspaceId } } to match every ID in that workspace. Actual query and mutation requests still require complete parameters.

Returned parameters describe normalized cache identity, not complete request arguments: nullish path/query fields are omitted, bodies use their JSON representation, and headers contain only lowercased names explicitly included through keyHeaders. Parameter objects are copied so editing them cannot change a query key.

Update matching existing entries with updateCachedQueries:

todos.updateCachedQueries(queryClient, {
  update: ({ data }) => {
    if (!data) return undefined;
    return {
      ...data,
      items: data.items.map((todo) =>
        todo.id === updatedTodo.id ? { ...todo, ...updatedTodo } : todo,
      ),
    };
  },
});

The updater receives { queryKey, params, data } for each entry. It runs synchronously against current data, must update immutably, and leaves an entry alone when it returns undefined. The helper does not create missing entries. Applications own list membership, sorting, pagination, optimistic rollback, and coordination with in-flight requests.

Both helpers recognize standard queries using the contract's generated keys, including entries seeded with setQueryData(helper.key(...), data). Store only the contract's raw response under these keys. Data is read before an observer's select transformation. Infinite queries and custom keys are excluded.

Standard query keys keep their existing format. Generated infinite-query keys append an "infinite" segment, so they remain distinct when manually seeded or dehydrated and hydrated. Use the queryKey returned by infiniteQueryOptions for direct reads, writes, and exact matching of infinite data. Namespace, contract, and parameter-prefix filters still match both query types.

When upgrading, discard persisted infinite-query caches created with the old key format. The "beignet" prefix is reserved: passing a custom key with that prefix throws. Omit key to use a generated key, and use ordinary TanStack cache APIs for custom keys and infinite-query data.

Beignet does not add query metadata. Global defaults, query-key defaults, and explicit meta options retain TanStack's normal precedence.

Declarative mutation invalidation

Pass invalidates to mutationOptions to select affected queries from the successful response and mutation variables. For an app with both list and detail queries, an update can refresh the affected detail and every list:

const mutation = useMutation(
  rq(updateTodo).mutationOptions({
    invalidates: (_todo, variables) => [
      rq(getTodo).filter({ path: variables.path }),
      rq(listTodos).contractFilter(),
    ],
    onSuccess: (todo) => {
      // Optional application work runs before invalidation.
      console.log("Saved", todo.id);
    },
  }),
);

The callback returns an array of ordinary TanStack invalidation filters and may be asynchronous. An empty array requests no invalidation. Put shared filter selection in a feature client function when mutations and realtime events should refresh the same queries.

Beignet uses the executing mutation's QueryClient, waits for its configured onSuccess, then attempts and awaits every requested invalidation before onSettled. Client-wide and mutation-key success defaults are honored unless overridden in mutationOptions. Success defaults are captured when the generated mutation function first starts, and retained across retries; changes to defaults while that request is pending apply to subsequent invocations.

Generated options can be composed and registered with setMutationDefaults. When composition retains an earlier success wrapper, each wrapper runs its invalidation once per mutation invocation, including when defaults refer back to the composed options.

Default TanStack behavior marks matching queries stale and refetches active eligible queries. Failed requests do not run invalidates.

Invalidation still runs when the configured onSuccess throws after a successful request. Callback or invalidation failures follow TanStack's post-success error handling and do not retry the successful HTTP write; multiple failures are reported as an AggregateError. Normal TanStack refetch error behavior remains in effect.

Pass lifecycle callbacks inside mutationOptions({ ... }). Spreading the returned options and replacing onSuccess replaces the invalidation wrapper. Callbacks passed to mutate(variables, { onSuccess }) keep TanStack's normal per-call behavior and run after the configured success handling.

These helpers require TanStack React Query 5.102.0 or newer in the v5 line. The executing client comes from TanStack's mutation callback context. They do not add write locks or defer polling and realtime refreshes during mutations.

Typed mutation errors

Mutation errors retain the endpoint contract's catalog codes. Declare business failures with .errors(...), then use helper.endpoint.isError(...) to narrow the error to one of those failures. For example, the starter's updateTodo contract declares TODO_NOT_FOUND:

const todo = rq(updateTodo);
const mutation = useMutation(
  todo.mutationOptions({
    invalidates: () => [rq(listTodos).contractFilter()],
    onError: (error) => {
      if (todo.endpoint.isError(error, { code: "TODO_NOT_FOUND" })) {
        console.log("Todo no longer exists:", error.details);
      } else if (error.hasStatus(422)) {
        console.log("Validation failed:", error.details);
      } else {
        console.log("Request failed:", error.body ?? error.message);
      }
    },
  }),
);

The same narrowing works on query errors. Keep using queryOptions() and mutationOptions() for requests; helper.endpoint exposes the typed client endpoint for error narrowing and direct client access.

Idempotency keys and retries

For contracts with idempotency metadata, the generated mutationFn derives one idempotency key per variables object 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" } });

Fresh variables objects passed to separate mutate(...) calls get separate keys, so retry stability does not normally deduplicate double-clicks. The integration keys retry state by variables-object identity: reusing the exact same object for a later intentional mutation also reuses its key. Create a fresh variables object for a distinct command, disable the submit button while the mutation is pending, or pass an explicit key when multiple 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.

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.

API reference