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

  1. A Gencow project with gencow codegen (or gencow dev) so you have a generated gencow/api.ts that exports api.
  2. An auth client from @gencow/client (`createAuthClient`).
  3. A runtime client from `createGencowClient` (apiClient).
  4. 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 — build tanstackApi.*.queryOptions({ input }) once (e.g. with useMemo) and pass the same object to multiple useQuery calls so they share one cache entry and one realtime subscription.
  • Pagination — pass TanStack options such as placeholderData: keepPreviousData into .queryOptions({ ... }) alongside input.
  • Realtime invalidation — mutations on the server push updates; GencowTanstackProvider updates 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-dom

That 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 default QueryClient with staleTime: 60_000 unless you pass your own)
  • Shared GencowContext from @gencow/react (useAuth, useGencowCtx, useRealtimeChannel work 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:

  1. Import api from the generated file.
  2. Create apiClient with createGencowClient, then tanstackApi with createTanstackQueryApiClient(apiClient).
  3. Use tanstackApi with TanStack hooks; use apiClient.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 dataqueryClient.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 GencowClientGencowTanstackApiClient
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