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