TanStack Query
@gencow/tanstack-query — GencowTanstackProvider, createTanstackQueryApiClient (experimental)
Experimental. APIs, codegen output, and integration guidance may change before a non-experimental release.
TanStack Query adapter for Gencow. It wraps your generated api object (from gencow codegen) into type-safe .queryOptions(), .mutationOptions(), and hierarchical cache keys for invalidation, prefetch, and cache updates.
This package assumes you already know TanStack Query. If you are new to it, read the official TanStack docs first.
When to use this package
| Approach | Packages | Best for |
|---|---|---|
| Built-in React hooks (default) | @gencow/client + @gencow/react |
Most React apps — useQuery / useMutation with realtime out of the box |
| TanStack Query (this package) | @gencow/client + @gencow/react + @gencow/tanstack-query + @tanstack/react-query |
Apps already on TanStack Query (useQuery, useInfiniteQuery, QueryClient, prefetch, suspense, etc.) |
Do not mix both stacks for the same data. Pick `@gencow/react` hooks or TanStack hooks backed by this adapter — not both for the same procedures.
| Approach | Root provider | Data hooks |
|---|---|---|
| Built-in React | GencowProvider + apiClient |
@gencow/react useQuery / useMutation |
| TanStack Query | GencowTanstackProvider + tanstackApi |
@tanstack/react-query + tanstackApi.*.queryOptions() |
Prerequisites
- A Gencow project with
gencow codegen(orgencow dev) so you have a generatedgencow/api.tsthat exportsapi. - An auth client from
@gencow/client(`createAuthClient`). - A runtime client from `createGencowClient` (
apiClient). - React 18+ and TanStack Query v5.
At the app root, use one Gencow provider only: GencowTanstackProvider with tanstackApi. It shares the same React session context as built-in hooks (useAuth, etc.). Do not nest <GencowProvider> for the same tree.
Architecture
Typical layering in a TanStack + Gencow app:
| Layer | Source | Role |
|---|---|---|
| Backend | gencow/ — defineApi, procedure.query, procedure.mutation, createCrud |
Procedures and realtime push |
| Generated defs | gencow/api.ts from codegen — defineProcedureQuery / defineProcedureMutation |
Typed api tree |
| Runtime client | createGencowClient({ api, baseUrl, auth }) → apiClient |
baseUrl, auth, imperative call.* |
| TanStack adapter | createTanstackQueryApiClient(apiClient) → tanstackApi |
.queryOptions(), keys, .call() on operations |
| UI | GencowTanstackProvider tanstackApi={tanstackApi} + @tanstack/react-query hooks |
Cache, pagination, shared queryKey across components |
Patterns that work well with this adapter:
- Shared
queryOptions— buildtanstackApi.*.queryOptions({ input })once (e.g. withuseMemo) and pass the same object to multipleuseQuerycalls so they share one cache entry and one realtime subscription. - Pagination — pass TanStack options such as
placeholderData: keepPreviousDatainto.queryOptions({ ... })alongsideinput. - Realtime invalidation — mutations on the server push updates;
GencowTanstackProviderupdates or invalidates matching cache keys (no manual refetch required for subscribed queries).
Keep runtime + TanStack wiring in a dedicated module (e.g. src/lib/apiClient.ts). Codegen overwrites gencow/api.ts — do not put createTanstackQueryApiClient inside generated files.
Installation
npm install @gencow/client @gencow/react @gencow/tanstack-query @tanstack/react-query react react-domThat single command installs everything this guide uses (@gencow/client, @gencow/react, @gencow/tanstack-query, TanStack Query v5, React 18+). Gencow packages are released with matching version numbers — use the same version for each @gencow/* package you install.
Setup
1. Auth client
// src/lib/auth.ts
import { createAuthClient } from "@gencow/client";
export const auth = createAuthClient(import.meta.env.VITE_API_URL);
// Optional: token strategy with in-memory session token for XSS-sensitive apps
// createAuthClient(url, { strategy: { kind: "token", sessionTokenStorage: "memory" } })2. Runtime + TanStack API clients
GencowClient and GencowTanstackApiClient are separate types with separate factories. Always create the runtime client first:
// src/lib/apiClient.ts
import { createGencowClient } from "@gencow/client";
import { createTanstackQueryApiClient } from "@gencow/tanstack-query";
import { api } from "../gencow/api";
import { auth } from "./auth";
const baseUrl = import.meta.env.VITE_API_URL;
export const apiClient = createGencowClient({ api, baseUrl, auth });
export const tanstackApi = createTanstackQueryApiClient(apiClient);createTanstackQueryApiClient mirrors the shape of api: every query becomes an operation with .queryOptions(), .infiniteOptions(), .call(), keys, etc.; every mutation gets .mutationOptions() and .call().
There is no createTanstackQueryApiClient(api, { baseUrl, auth }) overload.
Use tanstackApi with TanStack hooks; use apiClient.call.* or tanstackApi.*.call() for imperative calls outside React (auth caveats for SSR/scripts).
3. Provider (React)
GencowTanstackProvider supplies:
QueryClientProvider(creates a defaultQueryClientwithstaleTime: 60_000unless you pass your own)- Shared
GencowContextfrom@gencow/react(useAuth,useGencowCtx,useRealtimeChannelwork here) - Automatic realtime cache sync — WebSocket subscriptions for active queries, updating or invalidating cache entries when the server pushes changes
// src/App.tsx or app/providers.tsx
import { GencowTanstackProvider } from "@gencow/tanstack-query";
import { tanstackApi } from "@/lib/apiClient";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<GencowTanstackProvider tanstackApi={tanstackApi}>
{children}
</GencowTanstackProvider>
);
}Pass a custom queryClient when you need shared prefetch/hydration setup:
import { QueryClient } from "@tanstack/react-query";
const queryClient = new QueryClient({ /* your defaults */ });
<GencowTanstackProvider tanstackApi={tanstackApi} queryClient={queryClient}>
{children}
</GencowTanstackProvider>| Anti-pattern | Why |
|---|---|
<GencowProvider><GencowTanstackProvider> |
Duplicate auth subscriptions |
useAuth with no Gencow root provider |
Hooks throw |
@gencow/react useQuery + TanStack useQuery on same procedure |
Two caches, two realtime paths |
4. Use TanStack hooks in components
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { tanstackApi } from "@/lib/apiClient";
function TaskList() {
const { data, isLoading, error } = useQuery(
tanstackApi.tasks.list.queryOptions({ input: { status: "open" } }),
);
const create = useMutation(tanstackApi.tasks.create.mutationOptions());
if (isLoading) return <p>Loading…</p>;
if (error) return <p>Error</p>;
return (
<ul>
{data?.map((task) => (
<li key={task.id}>{task.title}</li>
))}
<button onClick={() => create.mutate({ title: "New task" })}>Add</button>
</ul>
);
}Shared query options (two panels, one cache key)
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { tanstackApi } from "@/lib/apiClient";
function PaginatedLists() {
const [page, setPage] = useState(1);
const limit = 5;
const sharedInput = useMemo(() => ({ page, limit }), [page]);
const queryOptions = tanstackApi.items.list.queryOptions({
input: sharedInput,
public: true,
placeholderData: keepPreviousData,
});
return (
<>
<button onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</button>
<button onClick={() => setPage((p) => p + 1)}>Next</button>
<Panel title="A" queryOptions={queryOptions} />
<Panel title="B" queryOptions={queryOptions} />
</>
);
}
function Panel({ title, queryOptions }: { title: string; queryOptions: ReturnType<typeof tanstackApi.items.list.queryOptions> }) {
const query = useQuery(queryOptions);
// Both panels share the same queryKey → one fetch, one realtime subscription
return <section>{title}: {query.isFetching ? "Fetching" : "Idle"}</section>;
}Generated api object
gencow codegen emits api with query and mutation definitions (from @gencow/client). Do not hand-write those defs in application code.
Your app should:
- Import
apifrom the generated file. - Create
apiClientwithcreateGencowClient, thentanstackApiwithcreateTanstackQueryApiClient(apiClient). - Use
tanstackApiwith TanStack hooks; useapiClient.call.*for imperative calls where auth is available.
Query options
Use .queryOptions() with useQuery, useSuspenseQuery, or queryClient.prefetchQuery.
useQuery(
tanstackApi.tasks.get.queryOptions({
input: { id: 123 },
staleTime: 60_000,
retry: 2,
}),
);queryKey and queryFn are set for you from the Gencow definition. You can pass any other UseQueryOptions field (staleTime, enabled, select, placeholderData, etc.).
Public (unauthenticated) queries
Protected queries throw if the user is not signed in. For endpoints that allow anonymous access (as declared in your Gencow schema / codegen), pass public: true:
tanstackApi.health.check.queryOptions({ public: true });Infinite query options
Use .infiniteOptions() with useInfiniteQuery or prefetchInfiniteQuery.
The input field must be a function that receives the page parameter and returns the procedure input:
useInfiniteQuery(
tanstackApi.tasks.list.infiniteOptions({
input: (offset: number | undefined) => ({ limit: 10, offset }),
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextOffset,
}),
);Mutation options
Use .mutationOptions() with useMutation:
const queryClient = useQueryClient();
const mutation = useMutation(
tanstackApi.tasks.create.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: tanstackApi.tasks.key() });
},
}),
);
mutation.mutate({ title: "Earth" });Calling procedures directly
Use tanstackApi.*.call() or apiClient.call.query / apiClient.call.mutate without React — useful in loaders, scripts, or server code. No provider is required for imperative calls.
const tasks = await tanstackApi.tasks.list.call({ status: "open" });
// or: await apiClient.call.query(api.tasks.list, { status: "open" });SSR / scripts: Protected procedures often return 401 unless the environment can send the user's token or session cookie. See Runtime client.
Query and mutation keys
Keys use a stable tuple shape: a path array plus an optional params object. Use methods on tanstackApi, not on the raw api defs.
| Method | Purpose |
|---|---|
.key() |
Partial-match prefix (invalidate broad groups) |
.queryKey({ input }) |
Full key for a standard query |
.infiniteKey({ input, initialPageParam }) |
Full key for an infinite query |
.mutationKey() |
Full key for a mutation |
.subscriptionKey({ input, type? }) |
WebSocket channel string for realtime, or null if not subscribable |
Examples:
const queryClient = useQueryClient();
// Invalidate all Gencow-backed queries
queryClient.invalidateQueries({ queryKey: tanstackApi.key() });
// Invalidate only queries under the `tasks` namespace
queryClient.invalidateQueries({ queryKey: tanstackApi.tasks.key() });
// Invalidate only regular (non-infinite) task list queries
queryClient.invalidateQueries({
queryKey: tanstackApi.tasks.list.key({ type: "query" }),
});
// Invalidate a specific list query
queryClient.invalidateQueries({
queryKey: tanstackApi.tasks.list.key({ type: "query", input: { status: "open" } }),
});
// Update cache for one query
queryClient.setQueryData(
tanstackApi.tasks.get.queryKey({ input: { id: 123 } }),
(old) => ({ ...old, title: "Updated" }),
);Default key prefix is ["gencow"]. Override when you need separate clients in one app:
const adminRuntime = createGencowClient({ api: adminApi, baseUrl, auth });
const adminTanstackApi = createTanstackQueryApiClient(adminRuntime, {
path: ["gencow", "admin"],
});Key shape (for debugging)
Standard query:
[["gencow", "tasks", "list", "query"], { "input": { "status": "open" } }]Infinite query (seed from first page input):
[["gencow", "tasks", "list", "infinite"], { "seed": { "offset": 0 } }]Mutation:
[["gencow", "tasks", "create", "mutation"]]Partial-match namespace:
[["gencow", "tasks"]]Conditionally disabling queries (skipToken)
Use skipToken instead of enabled: false when the query should not run because input is missing — it keeps types precise and sets enabled: false automatically.
import { skipToken } from "@gencow/tanstack-query";
const search = "...";
useQuery(
tanstackApi.tasks.list.queryOptions({
input: search ? { search } : skipToken,
}),
);
useInfiniteQuery(
tanstackApi.tasks.list.infiniteOptions({
input: search
? (offset) => ({ limit: 10, offset, search })
: skipToken,
initialPageParam: undefined,
getNextPageParam: (page) => page.nextOffset,
}),
);Skipped queries use { skip: true } in the key params segment and do not subscribe to realtime.
Realtime (WebSocket cache sync)
When GencowTanstackProvider is mounted, it watches the TanStack query cache. For each active query whose key maps to a subscription channel:
- Server push with data →
queryClient.setQueryData(queryKey, pushed) - Server push without data (invalidate signal) →
queryClient.invalidateQueries({ queryKey })
Subscription channels are derived automatically from each query's cache key.
- Standard queries: channel includes serialized input (e.g.
tasks.list::{"status":"open"}). - Infinite queries: channel is the operation name only (e.g.
tasks.list) — page args are not part of the channel.
Inspect or test channels explicitly:
tanstackApi.tasks.list.subscriptionKey({ input: { status: "open" } });
// → "tasks.list::{\"status\":\"open\"}" or similar
tanstackApi.subscriptionKeyFromQueryKey(someQueryKeyFromCache);Realtime requires a signed-in user (same as protected .call()). Use public: true only where your API allows anonymous reads.
Session hooks (useAuth, useGencowCtx)
Inside GencowTanstackProvider, import from @gencow/tanstack-query or @gencow/react:
import { useAuth, useGencowCtx } from "@gencow/tanstack-query";
const { user, isAuthenticated } = useAuth();
const { baseUrl, authStateKey } = useGencowCtx();Prefer user and isAuthenticated over reading JWTs in UI code. Also re-exported: useRealtimeChannel, useWorkflow (TanStack variant), GencowClientCtx.
useWorkflow (TanStack)
Track a workflow run by exact ID with TanStack useQuery plus realtime/polling:
import { useWorkflow } from "@gencow/tanstack-query";
const state = useWorkflow(tanstackApi.workflows.get, run.id);
// state: data, status, isActive, isTerminal, isLoading, error, refetch, ...Prefer tanstackApi.workflows.get.queryOptions alignment when wiring custom UI around the same run.
Prefetch and suspense
await queryClient.prefetchQuery(
tanstackApi.tasks.list.queryOptions({ input: { status: "open" } }),
);
const { data } = useSuspenseQuery(
tanstackApi.tasks.list.queryOptions({ input: { status: "open" } }),
);Use your own dehydrate/hydrate setup if you SSR — this package does not ship a custom serializer; follow TanStack Query hydration for your framework.
Exports
| Export | Description |
|---|---|
createTanstackQueryApiClient |
GencowClient → GencowTanstackApiClient |
GencowTanstackProvider |
Single root provider (TanStack + shared Gencow context) |
useAuth, useGencowCtx, useRealtimeChannel |
Re-exported from @gencow/react |
useWorkflow |
TanStack workflow polling + realtime |
skipToken |
Type-safe “no input” sentinel |
| Types | GencowTanstackApiClient, GencowQueryOptions, GencowMutationOptions, etc. |
Troubleshooting
| Symptom | Likely cause |
|---|---|
requires authentication on .call() / fetch |
User not signed in; use public: true only for anonymous endpoints |
| Realtime not updating | Query has no observers, key is skipped, or provider not mounted |
| Duplicate fetches / odd cache sharing | Two clients with the same path and inputs — use distinct path prefixes on createTanstackQueryApiClient |
| Mixed loading behavior | Using both @gencow/react useQuery and TanStack useQuery on the same procedure |
| 401 in SSR / scripts | No session token or cookie in that environment — forward credentials or use public endpoints |
See also
- Client SDK —
createGencowClient,apiClient, auth - React Hooks — built-in
GencowProvider/useQueryalternative - Authentication —
createAuthClientsetup - Realtime — server push model
- TanStack Query documentation
- `@gencow/tanstack-query` on npm