@gencow/tanstack-query
v0.2.4
Published
TanStack Query adapter for Gencow — queryOptions, mutationOptions, realtime cache sync
Readme
@gencow/tanstack-query
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 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 standardized on TanStack Query (useQuery, useInfiniteQuery, QueryClient, prefetch, suspense, etc.) |
Do not mix both stacks for the same data. Pick one of @gencow/react hooks or TanStack hooks backed by this adapter — not both for the same queries.
Prerequisites
- A Gencow project with
gencow codegen(orgencow dev) so you have a generatedgencow/api.tsthat exportsapi. - An auth client from
@gencow/client(createAuthClient). - React 18+ and TanStack Query v5.
Install @gencow/react as a peer — TanStack apps use one root provider (GencowTanstackProvider) that shares the same React context as built-in hooks. Do not nest <GencowProvider> for the same tree.
Installation
npm install @gencow/client @gencow/react @gencow/tanstack-query @tanstack/react-query react react-domPeer dependencies: @gencow/react, @tanstack/react-query (v5+), react (v18+).
Setup
1. Auth client
// 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
Create the framework-agnostic runtime client first, then the TanStack adapter (separate types and factories):
// lib/gencow.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(). Use apiClient.call.* for imperative/SSR calls outside React.
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
// app/providers.tsx
"use client";
import { GencowTanstackProvider } from "@gencow/tanstack-query";
import { tanstackApi } from "@/lib/gencow";
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>4. Use TanStack hooks in components
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/gencow-api";
function TaskList() {
const { data, isLoading, error } = useQuery(
apiClient.tasks.list.queryOptions({ input: { status: "open" } }),
);
const create = useMutation(apiClient.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>
);
}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/SSR calls.
Query options
Use .queryOptions() with useQuery, useSuspenseQuery, or queryClient.prefetchQuery.
useQuery(
apiClient.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, 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:
apiClient.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(
apiClient.tasks.list.infiniteOptions({
input: (offset: number | undefined) => ({ limit: 10, offset }),
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextOffset,
}),
);Mutation options
Use .mutationOptions() with useMutation:
const mutation = useMutation(
apiClient.tasks.create.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: apiClient.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" });Query and mutation keys
Keys use a stable tuple shape: a path array plus an optional params object. Use methods on apiClient, 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: apiClient.key() });
// Invalidate only queries under the `tasks` namespace
queryClient.invalidateQueries({ queryKey: apiClient.tasks.key() });
// Invalidate only regular (non-infinite) task list queries
queryClient.invalidateQueries({
queryKey: apiClient.tasks.list.key({ type: "query" }),
});
// Invalidate a specific list query
queryClient.invalidateQueries({
queryKey: apiClient.tasks.list.key({ type: "query", input: { status: "open" } }),
});
// Update cache for one query
queryClient.setQueryData(
apiClient.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(
apiClient.tasks.list.queryOptions({
input: search ? { search } : skipToken,
}),
);
useInfiniteQuery(
apiClient.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:
apiClient.tasks.list.subscriptionKey({ input: { status: "open" } });
// → "tasks.list::{\"status\":\"open\"}" or similar
apiClient.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();Also re-exported: useRealtimeChannel, useWorkflow (TanStack variant), GencowClientCtx.
Prefetch and suspense
// Server or client prefetch
await queryClient.prefetchQuery(
apiClient.tasks.list.queryOptions({ input: { status: "open" } }),
);
// Suspense
const { data } = useSuspenseQuery(
apiClient.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 apiClient instances with the same path and inputs — use distinct path prefixes |
| Mixed loading behavior | Using both @gencow/react useQuery and TanStack useQuery on the same procedure |
See also
@gencow/client— auth, generated defs, WebSocket client@gencow/react— built-inGencowProvider/useQueryalternative- Gencow documentation
- TanStack Query documentation
