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[] | undefinedMutations
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 helper | Scope | Use it for |
|---|---|---|
namespaceFilter() | Every contract in one namespace | A resource-wide write changed list, detail, search, or count data. |
contractFilter() | Every call to one contract | A write changed any filtered or paginated result from that contract. |
filter({ path, query, body }) | One parameter-scoped contract key | A 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.