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-querySetup
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 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 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.