# use-q
> An opinionated, schema-driven way to use TanStack Query v5 — TkDodo's Practical React Query patterns as a typed API client.
Complete documentation. Index: https://use-q.dev/llms.txt
---
# Introduction
> use-q is an opinionated, schema-driven way to use TanStack Query v5 — TkDodo's Practical React Query patterns as a typed API client.
`use-q` is an opinionated way to use [TanStack Query](https://tanstack.com/query/latest) v5. The opinions are Dominik Dorfmeister ([TkDodo](https://tkdodo.eu))'s, from the [Practical React Query](https://tkdodo.eu/blog/practical-react-query) series — encoded as a thin, schema-driven API client.
You describe your API once as a plain object of `RouteDefinition`s. From that single source of truth, `use-q` infers path params, search params, request bodies, and response shapes, builds hierarchical query keys, and wires up cache invalidation automatically through schema-defined tags. No `any` leaks into your components, and no query keys are written by hand.
You still use TanStack Query. `use-q` does not replace it or hide the `QueryClient` — it makes the recommended patterns the path of least resistance. See [Practical React Query](https://use-q.dev/docs/getting-started/practical-react-query.md) for how each API maps to TkDodo's posts.
## The packages
| Package | What it provides |
| --- | --- |
| `@use-q/api-client` | Framework-agnostic core: `createFetcher`, the `Schema` / `RouteDefinition` types, and error helpers. Zero runtime dependencies. |
| `@use-q/api-client-react` | `createApiClient` and the React hooks (`useQ`, `useM`, `useInfiniteQ`, `useSuspenseQ`, `useQClient`) on top of TanStack Query v5. |
| `@use-q/api-client-codegen` | Generates a typed schema from an OpenAPI 3.x spec. Ships the `use-q-codegen` CLI bin. |
The core runs anywhere `fetch` does — Node, Bun, Deno, edge workers, or the browser — so you can share one schema between server actions, loaders, scripts, and your React app.
## A quick taste
Define a schema and create a client:
```ts
import { createApiClient } from "@use-q/api-client-react";
import type { RouteDefinition } from "@use-q/api-client";
interface Post {
id: string;
title: string;
}
const schema = {
listPosts: {
method: "GET",
path: "/facilities/{facilityId}/posts",
tags: ["posts"],
} satisfies RouteDefinition<{ facilityId: string }, never, never, Post[]>,
createPost: {
method: "POST",
path: "/facilities/{facilityId}/posts",
invalidatesTags: ["posts"],
} satisfies RouteDefinition<{ facilityId: string }, never, { title: string }, Post>,
} as const;
export const api = createApiClient(schema, {
baseUrl: "https://api.example.com",
});
export const { useQ, useM, useQClient } = api;
```
Then use it in a component:
```tsx
function PostList({ facilityId }: { facilityId: string }) {
const { data, isLoading } = api.useQ("listPosts", { params: { facilityId } });
const createPost = api.useM("createPost");
if (isLoading) return
Loading…
;
return (
<>
{data?.map((p) => {p.title})}
>
);
}
```
That's it — the list refetches automatically after the mutation, because `createPost.invalidatesTags` matches `listPosts.tags`.
## Design principles
These are TkDodo's Practical React Query defaults, as a library:
- **End-to-end type safety.** Path params, search params, request bodies, and response shapes are all inferred from a single `RouteDefinition` map — not from `useQuery` generics.
- **A query abstraction, not a new mental model.** `createApiClient` is the colocated `queryKey` + `queryFn` layer. You still mount `QueryClientProvider` and pass `staleTime` / `select` / `enabled`.
- **Hierarchical query keys you never type twice.** Keys are always `["api", method, path, searchParams]`. Prefix invalidation and a `queryKeys` factory come for free.
- **Schema-driven invalidation.** Tag routes with `tags` / `invalidatesTags` and mutations refresh the right queries in `onSettled` through a shared `TagRegistry`.
- **Optimistic updates that compose.** Update multiple caches per mutation with snapshot/rollback semantics built in. Combine with `additionalInvalidatesTags` for arbitrary fan-out.
- **Framework-agnostic core.** `@use-q/api-client` is a tiny zero-dependency fetcher that runs in Node, Bun, Deno, edge workers, or the browser. Use it from server actions, loaders, or scripts — no React required.
- **OpenAPI codegen.** Generate a typed schema directly from an OpenAPI 3.x spec with `use-q-codegen` from `@use-q/api-client-codegen`. Pagination is detected automatically.
- **Bring your own QueryClient.** Reuse a single `QueryClient` across multiple `createApiClient` instances, or hook into persistence and devtools — `use-q` never gets in the way.
## Where to next?
- [Practical React Query](https://use-q.dev/docs/getting-started/practical-react-query.md): The opinions — and links to TkDodo's series
- [Install](https://use-q.dev/docs/getting-started/installation.md): Install `@use-q/api-client` and the React layer
- [5-minute quick start](https://use-q.dev/docs/getting-started/quick-start.md): Build a typed list + create flow
- [Define a schema](https://use-q.dev/docs/getting-started/schema-definition.md): Anatomy of a `RouteDefinition`
## LLM-readable docs
Agents can read these docs at [`/llms.txt`](https://use-q.dev/llms.txt) (an index of every page) or [`/llms-full.txt`](https://use-q.dev/llms-full.txt) (the complete corpus in one file). Each page is also available as Markdown by appending `.md` to its URL.
---
# Practical React Query
> use-q is an opinionated way to use TanStack Query — TkDodo's Practical React Query series, encoded as a schema-driven client.
`use-q` is not a new data-fetching library. It is an **opinionated way to use [TanStack Query](https://tanstack.com/query/latest)**.
The opinions come from Dominik Dorfmeister ([TkDodo](https://tkdodo.eu))'s [Practical React Query](https://tkdodo.eu/blog/practical-react-query) series: treat the cache as a server-state manager, never hand-write query keys, colocate a query abstraction, invalidate by describing relationships, transform with `select`, and keep TypeScript inference flowing from the query function.
You still mount a `QueryClientProvider`. You still pass `staleTime`, `select`, and `enabled`. `use-q` makes those recommended patterns the path of least resistance so every app doesn't reinvent a query-key factory and a mutation-invalidation graph.
> **Info:**
> This page is a map, not a restatement of the series. When you want the _why_ behind a default, follow the link to TkDodo. When you want the _how_ in this library, follow the use-q guide next to it.
## What use-q bakes in
### A query abstraction, not a new mental model
[Creating Query Abstractions](https://tkdodo.eu/blog/creating-query-abstractions) and [The Query Options API](https://tkdodo.eu/blog/the-query-options-api) argue for a single place that owns `queryKey` + `queryFn` (and the types). [`createApiClient`](https://use-q.dev/docs/react/create-api-client.md) is that place: one schema produces `useQ`, `useM`, `queryKeys`, and the fetcher. [React Query API Design — Lessons Learned](https://tkdodo.eu/blog/react-query-api-design-lessons-learned) is the longer argument for co-locating those.
### Hierarchical query keys you never type twice
[Effective React Query Keys](https://tkdodo.eu/blog/effective-react-query-keys): array keys, coarse-to-fine, plus a factory. use-q keys are always `["api", method, resolvedPath, searchParams]`. The client's `queryKeys` factory is the factory. The query function always receives TanStack's [`queryFn` context](https://tkdodo.eu/blog/leveraging-the-query-function-context) (including `signal`) — you don't thread it yourself. See [Query keys](https://use-q.dev/docs/guides/query-keys.md).
### Automatic invalidation after mutations
[Automatic Query Invalidation after Mutations](https://tkdodo.eu/blog/automatic-query-invalidation-after-mutations) is the problem `tags` / `invalidatesTags` solve. You describe which reads depend on which writes in the schema; `useM` invalidates matching queries in `onSettled`, as [Mastering Mutations](https://tkdodo.eu/blog/mastering-mutations-in-react-query) recommends — success _and_ error, so rollbacks still refetch the truth. See [Tag invalidation](https://use-q.dev/docs/guides/tag-invalidation.md).
### Transforms and render isolation via `select`
Don't map in the `queryFn`. Pass `select` to [`useQ`](https://use-q.dev/docs/react/use-q.md) — [data transformations](https://tkdodo.eu/blog/react-query-data-transformations), [render optimizations](https://tkdodo.eu/blog/react-query-render-optimizations), and [selectors, supercharged](https://tkdodo.eu/blog/react-query-selectors-supercharged).
### Types inferred from the schema, not from generics
[React Query and TypeScript](https://tkdodo.eu/blog/react-query-and-type-script) and [Type-safe React Query](https://tkdodo.eu/blog/type-safe-react-query): let the query function (here: the `RouteDefinition`) produce the types. You almost never pass generics to `useQ` / `useM`.
### Errors and status as Query intended them
[React Query Error Handling](https://tkdodo.eu/blog/react-query-error-handling) — handle errors where you have the context: globally (`onError` on the fetcher), per mutation, or with an error boundary ([`ApiErrorBoundary`](https://use-q.dev/docs/react/api-error-boundary.md) + [`useSuspenseQ`](https://use-q.dev/docs/react/use-suspense-q.md)). [Status Checks in React Query](https://tkdodo.eu/blog/status-checks-in-react-query) still apply: prefer `isPending` / `isError` / `isSuccess` over treating `isLoading` as the only gate.
### Optimistic updates that cancel in-flight queries
[`useM`](https://use-q.dev/docs/react/use-m.md)'s `optimisticUpdates` follow [Concurrent Optimistic Updates](https://tkdodo.eu/blog/concurrent-optimistic-updates-in-react-query): `cancelQueries`, snapshot, `setQueryData`, rollback on error, invalidate on settle. See [Optimistic updates](https://use-q.dev/docs/guides/optimistic-updates.md).
### Seed the cache; don't copy server state into React state
The cache _is_ the store for server data — [React Query as a State Manager](https://tkdodo.eu/blog/react-query-as-a-state-manager), [Thinking in React Query](https://tkdodo.eu/blog/thinking-in-react-query), [Why You Want React Query](https://tkdodo.eu/blog/why-you-want-react-query). Prefetch and hydrate with [`useQClient`](https://use-q.dev/docs/react/use-q-client.md) and [SSR & loaders](https://use-q.dev/docs/guides/ssr-and-loaders.md) ([seeding the query cache](https://tkdodo.eu/blog/seeding-the-query-cache), [placeholder vs initial data](https://tkdodo.eu/blog/placeholder-and-initial-data-in-react-query), [React Query meets React Router](https://tkdodo.eu/blog/react-query-meets-react-router)). Put a `QueryClient` in context, not query results — [React Query and React Context](https://tkdodo.eu/blog/react-query-and-react-context).
## What we don't bake in
The series covers more than a client library should own. use-q leaves these to your app — and to TkDodo:
- **WebSockets** — [Using WebSockets with React Query](https://tkdodo.eu/blog/using-web-sockets-with-react-query). Query is a cache; push updates into it with `useQClient().setData` / `queryClient.setQueriesData`.
- **Offline persistence** — [Offline React Query](https://tkdodo.eu/blog/offline-react-query). Pass your own `QueryClient` (see [Bring your own QueryClient](https://use-q.dev/docs/guides/byo-query-client.md)).
- **Forms** — [React Query and Forms](https://tkdodo.eu/blog/react-query-and-forms). `useM` submits; form state stays in the form library.
- **Testing** — [Testing React Query](https://tkdodo.eu/blog/testing-react-query). One `QueryClient` per test, `queries.retry: false`.
- **Whether you need Query at all** — [You Might Not Need React Query](https://tkdodo.eu/blog/you-might-not-need-react-query). use-q is for server state that benefits from a cache.
Two more posts are useful background without being APIs we wrap: [Inside React Query](https://tkdodo.eu/blog/inside-react-query) (how the observer model works) and [React Query — The Bad Parts](https://tkdodo.eu/blog/react-query-the-bad-parts) (query keys and invalidation boilerplate are two of the sharp edges this library exists to blunt). [TanStack Router and Query](https://tkdodo.eu/blog/tanstack-router-and-query) is the Router-specific integration; our [SSR & loaders](https://use-q.dev/docs/guides/ssr-and-loaders.md) guide shows the shared-cache pattern without duplicating that post.
## Feature → post
| In use-q | Read TkDodo |
| --- | --- |
| [`createApiClient`](https://use-q.dev/docs/react/create-api-client.md) | [#31 Creating Query Abstractions](https://tkdodo.eu/blog/creating-query-abstractions), [#24 The Query Options API](https://tkdodo.eu/blog/the-query-options-api) |
| [Query keys](https://use-q.dev/docs/guides/query-keys.md) + `queryKeys` | [#8 Effective React Query Keys](https://tkdodo.eu/blog/effective-react-query-keys) |
| [`tags` / `invalidatesTags`](https://use-q.dev/docs/guides/tag-invalidation.md) | [#25 Automatic Query Invalidation after Mutations](https://tkdodo.eu/blog/automatic-query-invalidation-after-mutations) |
| [`useQ`](https://use-q.dev/docs/react/use-q.md) `select` | [#2 Data Transformations](https://tkdodo.eu/blog/react-query-data-transformations), [#30 Selectors, Supercharged](https://tkdodo.eu/blog/react-query-selectors-supercharged) |
| `placeholderData` / `initialData` | [#9 Placeholder and Initial Data](https://tkdodo.eu/blog/placeholder-and-initial-data-in-react-query) |
| [`useM`](https://use-q.dev/docs/react/use-m.md) + `onSettled` invalidation | [#12 Mastering Mutations](https://tkdodo.eu/blog/mastering-mutations-in-react-query) |
| [Optimistic updates](https://use-q.dev/docs/guides/optimistic-updates.md) | [#29 Concurrent Optimistic Updates](https://tkdodo.eu/blog/concurrent-optimistic-updates-in-react-query) |
| [`useInfiniteQ`](https://use-q.dev/docs/react/use-infinite-q.md) | [#26 How Infinite Queries work](https://tkdodo.eu/blog/how-infinite-queries-work) |
| [`ApiErrorBoundary`](https://use-q.dev/docs/react/api-error-boundary.md) | [#11 Error Handling](https://tkdodo.eu/blog/react-query-error-handling) |
| [SSR & loaders](https://use-q.dev/docs/guides/ssr-and-loaders.md) | [#16 React Query meets React Router](https://tkdodo.eu/blog/react-query-meets-react-router), [#17 Seeding the Query Cache](https://tkdodo.eu/blog/seeding-the-query-cache) |
The series index — and every later part — lives on [Practical React Query (#1)](https://tkdodo.eu/blog/practical-react-query). There is also a [React Query FAQs](https://tkdodo.eu/blog/react-query-fa-qs) post for the questions that come up after the defaults click.
---
# Installation
> Install use-q and its React bindings with pnpm, npm, or yarn.
`use-q` ships as two installable packages plus an optional codegen CLI:
| Package | What it provides |
| --- | --- |
| `@use-q/api-client` | Framework-agnostic `createFetcher` + types. Zero runtime dependencies. |
| `@use-q/api-client-react` | `createApiClient` and hooks (`useQ`, `useM`, …) on top of TanStack Query v5. |
| `@use-q/api-client-codegen` | OpenAPI → `RouteDefinition` map generator. Ships the `use-q-codegen` CLI bin. |
## Requirements
- **Node.js 18 or newer.** `use-q` relies on global `fetch` and `AbortController`.
- For the React layer: **React 18+** and **`@tanstack/react-query` 5.x**.
## Install
**pnpm**
```bash
# Core only — server scripts, edge workers, RSC, loaders
pnpm add @use-q/api-client
# Plus the React bindings
pnpm add @use-q/api-client @use-q/api-client-react @tanstack/react-query react react-dom
```
**npm**
```bash
npm install @use-q/api-client
npm install @use-q/api-client @use-q/api-client-react @tanstack/react-query react react-dom
```
**yarn**
```bash
yarn add @use-q/api-client
yarn add @use-q/api-client @use-q/api-client-react @tanstack/react-query react react-dom
```
## Peer dependencies
`@use-q/api-client-react` declares peer dependencies that you must install in your app:
```json
{
"peerDependencies": {
"@tanstack/react-query": "^5.0.0",
"react": "^18.0.0 || ^19.0.0"
}
}
```
> **Tip:**
> If you're already using TanStack Query elsewhere in your app, you can share a
> single `QueryClient` across both — see
> [Bring your own QueryClient](https://use-q.dev/docs/guides/byo-query-client.md).
## TypeScript
`use-q` is written in strict TypeScript with `exactOptionalPropertyTypes` and `noUncheckedIndexedAccess`. For the best inference, enable strict mode in your `tsconfig.json` — [React Query and TypeScript](https://tkdodo.eu/blog/react-query-and-type-script) and [Type-safe React Query](https://tkdodo.eu/blog/type-safe-react-query) are why we never ask you to pass generics to `useQ` / `useM`.
```json
{
"compilerOptions": {
"strict": true,
"moduleResolution": "bundler",
"target": "ES2020"
}
}
```
## Verifying the install
After installing, you should be able to import from each package without type errors:
```ts
import { createFetcher, isApiError } from "@use-q/api-client";
import { createApiClient, ApiErrorBoundary } from "@use-q/api-client-react";
```
## Running the codegen CLI
The `use-q-codegen` bin is shipped by the `@use-q/api-client-codegen` package. Add it as a dev dependency, then run it:
```bash
pnpm add -D @use-q/api-client-codegen
pnpm exec use-q-codegen --input ./openapi.yaml --output ./src/api/schema.ts
```
Or with npm:
```bash
npm install -D @use-q/api-client-codegen
npx use-q-codegen --input ./openapi.yaml --output ./src/api/schema.ts
```
See [Codegen](https://use-q.dev/docs/core/codegen.md) for a full walkthrough.
## Next
Once everything's installed, head to the [Quick Start](https://use-q.dev/docs/getting-started/quick-start.md) for a five-minute end-to-end example.
---
# Quick start
> Build a type-safe list + create flow with use-q in five minutes.
This guide walks through a complete, runnable example: define a small schema, create a client, mount the provider, and use `useQ` / `useM` from a component.
We'll use a fictional API with three resources — `facilities`, `posts`, and `comments` — and a single `facilityId` path parameter. The same example is reused across the docs.
### Install the packages
```bash
pnpm add @use-q/api-client @use-q/api-client-react @tanstack/react-query react react-dom
```
See [Installation](https://use-q.dev/docs/getting-started/installation.md) for npm/yarn equivalents and peer deps.
### Define a schema
Create `src/api/schema.ts`. Each route is typed with `satisfies RouteDefinition` — the four type arguments carry path params, search params, request body, and response shape:
```ts
import type { RouteDefinition } from "@use-q/api-client";
export interface Post {
id: string;
facilityId: string;
title: string;
body: string;
createdAt: string;
}
export interface CreatePostInput {
title: string;
body: string;
}
export const schema = {
listPosts: {
method: "GET",
path: "/facilities/{facilityId}/posts",
tags: ["posts"],
} satisfies RouteDefinition<{ facilityId: string }, { search?: string }, never, Post[]>,
getPost: {
method: "GET",
path: "/facilities/{facilityId}/posts/{postId}",
tags: ({ params }) => [{ type: "post", id: params.postId }],
} satisfies RouteDefinition<{ facilityId: string; postId: string }, never, never, Post>,
createPost: {
method: "POST",
path: "/facilities/{facilityId}/posts",
invalidatesTags: ["posts"],
} satisfies RouteDefinition<{ facilityId: string }, never, CreatePostInput, Post>,
} as const;
```
### Create the client
In `src/api/client.ts`, instantiate the client once and re-export the hooks you'll use across the app:
```ts
import { createApiClient } from "@use-q/api-client-react";
import { schema } from "./schema";
export const api = createApiClient(schema, {
baseUrl: import.meta.env.VITE_API_BASE_URL ?? "https://api.example.com",
headers: () => ({
Authorization: `Bearer ${localStorage.getItem("token") ?? ""}`,
}),
});
export const { useQ, useM, useInfiniteQ, useQClient, queryClient } = api;
```
### Wrap your app in `QueryClientProvider`
`createApiClient` returns the underlying `queryClient` — use it directly so every hook shares the same cache:
```tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClientProvider } from "@tanstack/react-query";
import { api } from "./api/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
,
);
```
### Read with `useQ`
`useQ` takes the route id, an input object (`params` + `searchParams`), and optionally any TanStack `useQuery` options:
```tsx
import { useQ } from "./api/client";
export function PostList({ facilityId }: { facilityId: string }) {
const { data, isLoading, error } = useQ("listPosts", {
params: { facilityId },
searchParams: { search: "" },
});
if (isLoading) return
Loading posts…
;
if (error) return
Failed to load: {error.message}
;
return (
{data?.map((post) => (
{post.title}
))}
);
}
```
### Write with `useM`
`useM` takes the route id (plus optional mutation options); path params, body, and search params are all passed as variables to `mutate()`:
```tsx
import { useM } from "./api/client";
export function NewPostForm({ facilityId }: { facilityId: string }) {
const createPost = useM("createPost");
return (
);
}
```
Because `createPost.invalidatesTags` matches `listPosts.tags`, the list refetches automatically once the mutation settles — no manual invalidation required. Form fields stay in the form; the mutation only submits ([React Query and Forms](https://tkdodo.eu/blog/react-query-and-forms)).
## What just happened?
- The `schema` object is the single source of truth. TypeScript infers all path params, search params, body, and response types from it.
- `createApiClient` built a `QueryClient`, a `TagRegistry`, and a bundle of hooks bound to your schema.
- `useQ("listPosts", …)` produced a query key of the form `["api", "GET", "/facilities/abc/posts", { search: "" }]` — deterministic and never hand-written.
- `useM("createPost")` ran the mutation, then asked the registry for every query whose tags matched and invalidated them in `onSettled`.
> **Tip:**
> Want to make the create flow feel instant? Add optimistic updates — see the
> [Optimistic Updates guide](https://use-q.dev/docs/guides/optimistic-updates.md).
## Next steps
- [Practical React Query](https://use-q.dev/docs/getting-started/practical-react-query.md) — why these defaults, with links to TkDodo's series.
- [Schema definition](https://use-q.dev/docs/getting-started/schema-definition.md) — a full tour of every `RouteDefinition` field.
- [`useQ`](https://use-q.dev/docs/react/use-q.md) — every option, including `select`, `enabled`, and `refetchInterval`.
- [`useM`](https://use-q.dev/docs/react/use-m.md) — optimistic updates and tag chaining.
- [`useQClient`](https://use-q.dev/docs/react/use-q-client.md) — manual cache control.
---
# Schema definition
> Anatomy of a RouteDefinition — every field, with examples.
A `use-q` schema is a plain object literal where each key is a route name and each value is a `RouteDefinition`. Each route is checked with `satisfies RouteDefinition`, and the whole map ends with `as const` so TypeScript keeps literal types and can infer every parameter and response down to the field level.
```ts
import type { RouteDefinition } from "@use-q/api-client";
export const schema = {
// : { ... } satisfies RouteDefinition,
} as const;
```
The four type arguments are:
| Slot | Meaning |
| --- | --- |
| `TParams` | Path parameters — keys for every `{placeholder}` in `path`. |
| `TSearch` | Query-string shape. |
| `TBody` | Request body (for mutating methods). |
| `TResponse` | Success-response shape. |
Use `never` for slots a route doesn't need. Below is a tour of every runtime field a `RouteDefinition` supports, using the running `facilities → posts → comments` example.
## `method` and `path`
`method` is one of `"GET" | "POST" | "PUT" | "PATCH" | "DELETE"`. `path` is a literal string with `{paramName}` placeholders.
```ts
{
method: "GET",
path: "/facilities/{facilityId}/posts/{postId}",
}
```
Path placeholders are filled from the `params` object you pass at the call site. If a placeholder has no corresponding value at request time, the fetcher throws a `Missing path parameter` error.
## Path parameters (`TParams`)
The first type argument declares the path-parameter shape. There is no runtime field — the type flows straight into every hook:
```ts
listComments: {
method: "GET",
path: "/facilities/{facilityId}/posts/{postId}/comments",
} satisfies RouteDefinition<
{ facilityId: string; postId: string },
never,
never,
Comment[]
>,
```
At the call site, all keys are required:
```ts
useQ("listComments", {
params: { facilityId: "f1", postId: "p1" },
});
```
## Search parameters (`TSearch`)
The second type argument describes the query-string shape. Optional properties (`search?:`) become optional at the call site, and `undefined` values are stripped from the URL.
```ts
listPosts: {
method: "GET",
path: "/facilities/{facilityId}/posts",
} satisfies RouteDefinition<
{ facilityId: string },
{
search?: string;
tag?: string;
limit?: number;
sort?: "newest" | "oldest";
},
never,
Post[]
>,
```
The resolved search params become part of the query key (with keys sorted, so ordering doesn't matter), so two requests with different `searchParams` get independent cache entries.
## Request body (`TBody`)
The third type argument, for mutating methods. Strongly typed; `use-q` calls `JSON.stringify` for you and sets `Content-Type: application/json`.
```ts
createPost: {
method: "POST",
path: "/facilities/{facilityId}/posts",
} satisfies RouteDefinition<
{ facilityId: string },
never,
{ title: string; body: string; tags?: string[] },
Post
>,
```
## Response (`TResponse`)
The fourth type argument is the success-response shape. This is the type of `data` from `useQ` and the resolved value of `useM(...).mutateAsync(...)`.
```ts
satisfies RouteDefinition<..., ..., ..., Post>
// or
satisfies RouteDefinition<..., ..., ..., { items: Post[]; total: number }>
// or
satisfies RouteDefinition<..., ..., ..., void> // 204 No Content
```
## `tags`
Labels what a query route _reads_, for tag-based invalidation. A `Tag` is either a plain string or `{ type: string; id?: string | number }`. The field accepts a static array, or a function that derives tags from the fetched response and the resolved params:
```ts
listPosts: {
// …
tags: ["posts"],
},
getPost: {
// …
tags: ({ params }) => [{ type: "post", id: params.postId }],
// or derive from the response instead:
// tags: ({ response }) => [{ type: "post", id: response?.id }],
},
```
### Static vs dynamic tags
A static tag is fixed for every query of that route:
```ts
listSettings: {
// …
tags: ["settings"],
}
```
A dynamic tag uses the function form to derive an `id` from the response or params, so each query instance registers its own tag:
```ts
tags: ({ params }) => [{ type: "post", id: params.postId }];
```
Matching is exact: tags are normalized to `"type"` (no id) or `"type:id"` strings, and a mutation invalidates precisely the queries registered under the same normalized tag. So invalidating `{ type: "post", id: "p1" }` refetches queries tagged `{ type: "post", id: "p1" }` — not queries tagged plain `"post"` or `{ type: "post", id: "p2" }`. To refresh both a list and a detail view, register (and invalidate) both tags.
## `invalidatesTags`
The mirror of `tags`, but for mutations: what data does this route _write_? Listed tags are invalidated in `onSettled`. Like `tags`, it accepts a static array or a function — the function receives the mutation `response` and the `variables` (`{ params?, body? }`) that were passed to `mutate()`:
```ts
createPost: {
// …
invalidatesTags: ["posts"],
},
deletePost: {
// …
invalidatesTags: ({ variables }) => [
"posts",
{ type: "post", id: variables.params?.postId },
],
},
```
> **Info:**
> Tag invalidation runs in `onSettled` — both success and error — so retries
> refetch consistently. That's [automatic invalidation after mutations](https://tkdodo.eu/blog/automatic-query-invalidation-after-mutations). See [Tag invalidation](https://use-q.dev/docs/guides/tag-invalidation.md) for
> the full lifecycle.
## `pagination`
For routes that return paginated data. The shape tells `useInfiniteQ` how to derive the page param and `getNextPageParam`.
### Page-number pagination
```ts
listPosts: {
method: "GET",
path: "/facilities/{facilityId}/posts",
pagination: {
kind: "page-number",
pageParam: "page",
itemsKey: "items", // default "items"
totalKey: "total", // default "total"
},
} satisfies RouteDefinition<
{ facilityId: string },
{ page?: number; limit?: number },
never,
{ items: Post[]; total: number }
>,
```
### Cursor pagination
```ts
listFeed: {
method: "GET",
path: "/feed",
pagination: {
kind: "cursor",
pageParam: "cursor",
cursorKey: "nextCursor", // default "nextCursor"
itemsKey: "items", // default "items"
},
} satisfies RouteDefinition<
never,
{ cursor?: string; limit?: number },
never,
{ items: Post[]; nextCursor: string | null }
>,
```
See [`useInfiniteQ`](https://use-q.dev/docs/react/use-infinite-q.md) for the consuming side.
## Putting it together
```ts
import type { RouteDefinition } from "@use-q/api-client";
interface Post {
id: string;
facilityId: string;
title: string;
body: string;
}
interface Comment {
id: string;
postId: string;
author: string;
body: string;
}
export const schema = {
listPosts: {
method: "GET",
path: "/facilities/{facilityId}/posts",
tags: ["posts"],
} satisfies RouteDefinition<{ facilityId: string }, { search?: string }, never, Post[]>,
getPost: {
method: "GET",
path: "/facilities/{facilityId}/posts/{postId}",
tags: ({ params }) => [{ type: "post", id: params.postId }],
} satisfies RouteDefinition<{ facilityId: string; postId: string }, never, never, Post>,
createPost: {
method: "POST",
path: "/facilities/{facilityId}/posts",
invalidatesTags: ["posts"],
} satisfies RouteDefinition<
{ facilityId: string },
never,
{ title: string; body: string },
Post
>,
updatePost: {
method: "PATCH",
path: "/facilities/{facilityId}/posts/{postId}",
invalidatesTags: ({ variables }) => [
"posts",
{ type: "post", id: variables.params?.postId },
],
} satisfies RouteDefinition<
{ facilityId: string; postId: string },
never,
Partial>,
Post
>,
deletePost: {
method: "DELETE",
path: "/facilities/{facilityId}/posts/{postId}",
invalidatesTags: ({ variables }) => [
"posts",
{ type: "post", id: variables.params?.postId },
],
} satisfies RouteDefinition<{ facilityId: string; postId: string }, never, never, void>,
listComments: {
method: "GET",
path: "/facilities/{facilityId}/posts/{postId}/comments",
tags: ({ params }) => [{ type: "comments", id: params.postId }],
} satisfies RouteDefinition<
{ facilityId: string; postId: string },
never,
never,
Comment[]
>,
} as const;
```
## Tips
- Keep your schema in a shared package if you have a monorepo — see [Monorepo usage](https://use-q.dev/docs/guides/monorepo-usage.md).
- Don't forget `as const` on the schema object — without it, literal types widen to `string` and inference breaks.
- Use the [codegen CLI](https://use-q.dev/docs/core/codegen.md) to generate a starting schema from an OpenAPI spec, then hand-edit tags.
---
# createFetcher
> Use the framework-agnostic fetcher from @use-q/api-client standalone — in Node CLIs, edge workers, RSC, and server actions.
`createFetcher` is the framework-agnostic core of `use-q`. It takes a configuration object (base URL, headers, error handling) and returns a small `FetcherInstance` whose `fetch` method resolves URLs, encodes bodies, and normalizes errors. Use it anywhere a `QueryClient` would be overkill — Node scripts, edge workers, server actions, React Server Components, route loaders.
```ts
import { createFetcher } from "@use-q/api-client";
import type { Post } from "./schema";
const fetcher = createFetcher({
baseUrl: "https://api.example.com",
});
const posts = await fetcher.fetch("/facilities/{facilityId}/posts", {
params: { facilityId: "f1" },
});
// ^? Post[]
```
> **Info:**
> `createFetcher` doesn't take a schema — it's a low-level, path-based HTTP client. Route-aware, schema-typed calls are what `createApiClient` and its hooks layer on top (they call this same fetcher internally). See [createApiClient](https://use-q.dev/docs/react/create-api-client.md).
## Returned shape
`createFetcher` returns a `FetcherInstance`:
```ts
interface FetcherInstance {
baseUrl: string;
fetch(
path: string,
options?: {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; // default "GET"
params?: Record; // fills {placeholders}
searchParams?: SearchParams;
body?: unknown;
signal?: AbortSignal;
headers?: HeadersInit; // per-call overrides
},
): Promise;
}
```
The `path` may contain `{param}` placeholders, which are filled (URI-encoded) from `params` — a missing placeholder value throws. `searchParams` are appended to the URL; `undefined`/`null` values are skipped and array values become repeated keys (`?id=1&id=2`). For `POST`/`PUT`/`PATCH`/`DELETE` with a `body`, the body is `JSON.stringify`-ed and `Content-Type: application/json` is added unless you supplied your own. Empty response bodies resolve to `undefined`; JSON responses are parsed when the content-type is `application/json`, everything else resolves as text.
If `path` is an absolute `http(s)://` URL, `baseUrl` joining is skipped and the URL is used as-is.
HTTP failures throw an `ApiError` — see [Error handling](https://use-q.dev/docs/core/error-handling.md).
## Options
### `baseUrl`
Prepended to every request path. Trailing slashes are normalized.
```ts
createFetcher({ baseUrl: "https://api.example.com/v1" });
```
### `fetch`
Inject a custom `fetch` implementation. Useful for tests, custom runtimes, or wrapping fetch with retry/auth/logging middleware. Defaults to `globalThis.fetch`.
```ts
import { fetch as undiciFetch } from "undici";
const fetcher = createFetcher({
baseUrl: "https://api.example.com",
fetch: undiciFetch,
});
```
### `headers`
Headers attached to every request. Any `HeadersInit` works (plain object, `Headers` instance, entry array), or a sync/async function returning one — the function is called and awaited on every request, so it's a great place to read a token from a store.
```ts
// Static
createFetcher({
baseUrl: "https://api.example.com",
headers: {
"x-api-version": "2026-01-01",
},
});
// Dynamic / async
createFetcher({
baseUrl: "https://api.example.com",
headers: async () => ({
Authorization: `Bearer ${await getAccessToken()}`,
"x-tenant-id": currentTenantId(),
}),
});
```
Per-call `headers` overrides win over defaults:
```ts
await fetcher.fetch("/facilities/{facilityId}/posts/{postId}", {
params: { facilityId: "f1", postId: "p1" },
headers: { "x-debug": "1" },
});
```
### `parseError`
Convert error responses into your domain's error shape. `parseError` runs only for non-2xx responses and receives the raw `Response` plus the already-parsed body:
```ts
parseError?: (input: { response: Response; data: unknown }) => unknown;
```
Whatever you return becomes the thrown `ApiError`'s `.data`. If you return an `ApiError` instance, it's thrown as-is (letting you fully control the error object).
```ts
import { isApiError } from "@use-q/api-client";
interface MyApiError {
code: string;
detail: string;
fieldErrors?: Record;
}
const fetcher = createFetcher({
baseUrl: "https://api.example.com",
parseError: ({ response, data }) => {
const body = data as Partial | null;
return {
code: body?.code ?? "unknown",
detail: body?.detail ?? response.statusText,
fieldErrors: body?.fieldErrors,
} satisfies MyApiError;
},
});
try {
await fetcher.fetch("/facilities/{facilityId}/posts", {
method: "POST",
params: { facilityId: "f1" },
body: { title: "" },
});
} catch (err) {
if (isApiError(err)) {
console.error(err.data.code, err.data.fieldErrors);
}
}
```
### `onError`
A side-effecting hook called for every failure, after error normalization. For HTTP failures it receives the `ApiError` about to be thrown; for network failures (fetch itself rejecting) it receives the raw error. Useful for telemetry or auth fan-out.
```ts
import { isApiError } from "@use-q/api-client";
createFetcher({
baseUrl: "https://api.example.com",
onError: (err) => {
if (isApiError(err) && err.status === 401) {
logoutAndRedirect();
}
telemetry.captureException(err);
},
});
```
`onError` receives a single argument — the error. HTTP `ApiError`s carry `url`, `method`, `status`, and `statusText` fields, so contextual data is available on the error itself.
## Recipes
### Node CLI
```ts
#!/usr/bin/env node
import { createFetcher } from "@use-q/api-client";
import type { Post } from "./schema.js";
const fetcher = createFetcher({
baseUrl: process.env.API_BASE_URL!,
headers: () => ({
Authorization: `Bearer ${process.env.API_TOKEN!}`,
}),
});
const posts = await fetcher.fetch("/facilities/{facilityId}/posts", {
params: { facilityId: process.argv[2]! },
});
console.table(posts.map(({ id, title }) => ({ id, title })));
```
### Cloudflare Worker
```ts
import { createFetcher } from "@use-q/api-client";
import type { Post } from "./schema";
export default {
async fetch(req: Request, env: Env): Promise {
const fetcher = createFetcher({
baseUrl: env.API_BASE_URL,
fetch: fetch, // Workers global fetch
headers: { "x-internal": env.INTERNAL_KEY },
});
const posts = await fetcher.fetch("/facilities/{facilityId}/posts", {
params: { facilityId: "f1" },
});
return Response.json(posts);
},
} satisfies ExportedHandler;
```
### Next.js Server Action
```ts
"use server";
import { createFetcher } from "@use-q/api-client";
import { cookies } from "next/headers";
import type { Post } from "@/api/schema";
const fetcher = createFetcher({
baseUrl: process.env.API_BASE_URL!,
headers: async () => ({
Authorization: `Bearer ${cookies().get("token")?.value ?? ""}`,
}),
});
export async function createPost(facilityId: string, formData: FormData) {
return fetcher.fetch("/facilities/{facilityId}/posts", {
method: "POST",
params: { facilityId },
body: {
title: String(formData.get("title")),
body: String(formData.get("body")),
},
});
}
```
### React Router data loader
```ts
import { createFetcher } from "@use-q/api-client";
import type { LoaderFunctionArgs } from "react-router-dom";
import type { Post } from "./schema";
const fetcher = createFetcher({
baseUrl: import.meta.env.VITE_API_BASE_URL,
});
export async function postsLoader({ params, request }: LoaderFunctionArgs) {
return fetcher.fetch("/facilities/{facilityId}/posts", {
params: { facilityId: params.facilityId! },
signal: request.signal,
});
}
```
> **Tip:**
> Want to hydrate loader data into a React-side cache? See [SSR & loaders](https://use-q.dev/docs/guides/ssr-and-loaders.md).
## Why not just use `fetch`?
You get four things `fetch` doesn't give you:
1. **URL building.** Path placeholders are filled and URI-encoded (with a loud error when a value is missing), `undefined`/`null` search params are dropped, arrays become repeated keys, JSON bodies are serialized with the right `Content-Type`.
2. **Consistent error shape.** Non-2xx responses always throw an `ApiError` (optionally shaped by your `parseError`).
3. **Centralized headers and error hooks.** Async header factories and a single `onError` for telemetry/auth, instead of copy-pasted boilerplate per call.
4. **A swap-in path to React.** The same `CreateFetcherOptions` are accepted by `createApiClient`, which pairs the fetcher with a schema for fully-typed hooks and caching.
---
# Error handling
> ApiError, isApiError, custom parseError shapes, and global error hooks.
Every non-2xx response from a `use-q` fetcher (or hook) throws an `ApiError`. That's it — one shape, every time, fully typed.
How you _handle_ that error is still TkDodo's [error-handling](https://tkdodo.eu/blog/react-query-error-handling) advice: globally (`onError` on the fetcher), per call (`useM` / `useQ` callbacks), or with an error boundary (`ApiErrorBoundary` + suspense). Pick the layer that has the context.
## `ApiError`
`ApiError` is a regular `Error` subclass with extra fields:
```ts
class ApiError extends Error {
readonly name: "ApiError";
readonly status: number;
readonly statusText: string;
readonly data: TData;
readonly url: string;
readonly method: string;
}
```
The default `message` is `"METHOD url failed with status statusText"` (e.g. `"GET https://api.example.com/posts/missing failed with 404 Not Found"`).
The generic `TData` is whatever your `parseError` returns. Without `parseError`, `data` is the parsed response body — JSON when the response's content-type is `application/json`, the raw text otherwise, or `undefined` for an empty body.
## `isApiError` type guard
Use `isApiError` to narrow errors safely:
```ts
import { isApiError } from "@use-q/api-client";
try {
await fetcher.fetch("/facilities/{facilityId}/posts/{postId}", {
params: { facilityId: "f1", postId: "missing" },
});
} catch (err) {
if (isApiError(err)) {
console.error(err.status, err.url, err.data);
} else {
throw err;
}
}
```
In React, the same guard works inside `useM`'s `onError` (it's also re-exported from `@use-q/api-client-react` and available as `api.isApiError` on the client object):
```tsx
import { isApiError } from "@use-q/api-client-react";
const createPost = useM("createPost", {
onError: (err) => {
if (isApiError(err) && err.status === 422) {
toast.error("Validation failed");
}
},
});
createPost.mutate({ params: { facilityId }, body: { title, body } });
```
## Customizing the parsed shape
Most APIs return structured error bodies. Normalize them with `parseError` and narrow with the type guard's generic, `isApiError`:
```ts
interface ApiProblem {
type: string;
title: string;
detail: string;
errors?: Record;
}
const fetcher = createFetcher({
baseUrl: "https://api.example.com",
parseError: ({ response, data }) => {
const json = data as Partial | null;
return {
type: json?.type ?? "about:blank",
title: json?.title ?? response.statusText,
detail: json?.detail ?? "",
errors: json?.errors,
} satisfies ApiProblem;
},
});
```
`parseError` receives `{ response, data }` — the raw `Response` plus the already-parsed body (JSON when possible, text otherwise). Its return value becomes `ApiError.data`. Downstream catch blocks narrow with `isApiError`:
```ts
catch (err) {
if (isApiError(err)) {
if (err.data.errors) {
for (const [field, messages] of Object.entries(err.data.errors)) {
form.setError(field, { message: messages[0] });
}
}
}
}
```
The same `parseError` option is accepted by `createApiClient`, so hook errors carry the same shape:
```ts
const api = createApiClient(schema, {
baseUrl: "https://api.example.com",
parseError: ({ response, data }) => /* … */,
});
```
## Global error handling
`onError` runs for every failure across every route. It's the right place to put cross-cutting concerns.
### Logout on 401
```ts
import { isApiError } from "@use-q/api-client";
const fetcher = createFetcher({
baseUrl: "https://api.example.com",
onError: (err) => {
if (isApiError(err) && err.status === 401) {
tokenStore.clear();
window.location.assign("/login");
}
},
});
```
### Telemetry
```ts
import * as Sentry from "@sentry/browser";
import { isApiError } from "@use-q/api-client";
createFetcher({
baseUrl: "https://api.example.com",
onError: (err) => {
Sentry.captureException(err, {
tags: {
method: isApiError(err) ? err.method : "unknown",
status: isApiError(err) ? String(err.status) : "network",
},
extra: { url: isApiError(err) ? err.url : undefined },
});
},
});
```
`onError` receives a single argument — the error itself. For HTTP failures that's the normalized `ApiError` (so `method`, `url`, `status` are right there on it); for network failures it's the raw thrown error.
### Per-call `onError` (React)
`useM` also accepts TanStack Query's `onError` callback. Both run — the fetcher-level `onError` first, then the mutation's:
```tsx
useM("createPost", {
onError: (err) => toast.error(`Couldn't save: ${err.message}`),
});
```
## Network errors
A thrown `TypeError: Failed to fetch` (no response) propagates _without_ being wrapped — your `onError` still runs, but `isApiError` returns `false`. Handle both cases:
```ts
catch (err) {
if (isApiError(err)) {
// HTTP-level error
} else if (err instanceof TypeError) {
// Network down, CORS, DNS…
} else {
throw err;
}
}
```
## React: ``
When you use `useSuspenseQ`, errors stop bubbling through render — they bubble to the nearest error boundary. `ApiErrorBoundary` catches `ApiError`s specifically (anything else is re-thrown to the next boundary) and hands your `fallback` a pre-narrowed error plus a `reset`:
```tsx
import { ApiErrorBoundary } from "@use-q/api-client-react";
(
)}
>
Loading…}>
;
```
The `fallback` receives `{ error, reset }` — `error` is already an `ApiError`, so no guard is needed inside. See [``](https://use-q.dev/docs/react/api-error-boundary.md) for `reset()` semantics.
> **Warning:**
> `parseError` should return a value, not throw. Its return value becomes `ApiError.data` — unless you return an `ApiError` instance, in which case that instance is thrown as-is (useful when you want to control the message or subclass `ApiError` yourself).
---
# Codegen
> Generate a typed RouteDefinition map from an OpenAPI 3.x spec with use-q-codegen.
`use-q-codegen` turns an OpenAPI 3.x document (JSON or YAML) into a fully-typed `RouteDefinition` map you can drop straight into `createApiClient` or `createFetcher`.
## Running the CLI
The binary ships with the `@use-q/api-client-codegen` package, so install that (typically as a dev dependency) and call it via your package manager:
```bash
pnpm add -D @use-q/api-client-codegen
pnpm exec use-q-codegen --input ./openapi.json --output ./src/api/schema.ts
```
```bash
npx use-q-codegen --input ./openapi.yaml --output ./src/api/schema.ts --base-url https://api.example.com
```
### CLI flags
| Flag | Description |
| --- | --- |
| `--input`, `-i` | Path to an OpenAPI 3.x spec (`.json`, `.yaml`, or `.yml`). Required. May also be passed as a bare positional argument. |
| `--output`, `-o` | Path to write the generated `schema.ts`. If omitted, the generated source is printed to stdout. |
| `--base-url`, `-b` | Optional. If provided, emitted as `export const baseUrl = "…"` alongside the schema. |
| `--help`, `-h` | Print usage and exit. |
YAML inputs use `js-yaml` under the hood, so anchors and multi-line strings are supported. Non-3.x specs are rejected with an error.
## What gets generated
The emitter produces a single TypeScript file with three sections:
```ts
// =============================================================================
// AUTO-GENERATED by @use-q/api-client-codegen — DO NOT EDIT BY HAND.
// =============================================================================
import type { RouteDefinition } from "@use-q/api-client";
// 1. Optional base URL (only with --base-url)
export const baseUrl = "https://api.example.com";
// 2. components/schemas → exported type aliases
export type Post = {
id: string;
facilityId: string;
title: string;
body: string;
createdAt: string;
};
// 3. RouteDefinition map, ready for createApiClient
export const schema = {
listPosts: {
method: "GET",
path: "/facilities/{facilityId}/posts",
tags: ["posts"] as const,
pagination: { kind: "page-number", pageParam: "page" } as const,
} satisfies RouteDefinition<
{ facilityId: string },
{ search?: string; page?: number; limit?: number },
never,
{ items: Array; total: number }
>,
createPost: {
method: "POST",
path: "/facilities/{facilityId}/posts",
} satisfies RouteDefinition<
{ facilityId: string },
Record,
{ title: string; body: string },
Post
>,
// …
} as const satisfies Record>;
export type Schema = typeof schema;
```
Per-route types (path params, search params, body, response) are carried entirely by the `satisfies RouteDefinition` clause — there are no extra runtime fields. Operation-level `tags` from the spec are emitted as static string arrays.
Route keys use the OpenAPI `operationId`. If an operation has no `operationId`, the emitter falls back to the literal `"METHOD path"` string (e.g. `"GET /posts/{id}"`) — both forms work as schema keys, `operationId` is just more ergonomic in user code.
### `$ref` resolution
`$ref` pointers into `components/schemas` are emitted by name — each component becomes an exported type alias, and references use that name. Composition keywords are handled like so:
| OpenAPI | TS |
| --- | --- |
| `allOf` | intersection (`A & B`) |
| `oneOf` | union (`A \| B`) |
| `anyOf` | union (`A \| B`) |
| `enum` | literal union (`"a" \| "b"`, numbers and booleans included) |
| `nullable: true` | `T \| null` |
## Pagination detection
The emitter inspects the operation's `200` JSON response schema (following one level of `$ref`) and infers a `pagination` block automatically:
| Response shape | Inferred `pagination.kind` |
| --- | --- |
| Object with `items` and `nextCursor` (or `next_cursor`) properties | `"cursor"` |
| Object with `items` and `total` properties | `"page-number"` |
| Array or anything else | _omitted_ |
The `pageParam` is taken from the operation's query parameters: for cursor pagination, the first query parameter whose name contains `cursor` (case-insensitive); for page-number pagination, a query parameter named `page`, `pageNumber`, or `page_number` (case-insensitive). If the response shape matches but no such parameter exists, the `pagination` block is simply omitted — you can hand-add it.
> **Tip:**
> If your API uses an unusual pagination convention, add or adjust the `pagination` block by hand — `{ kind: "page-number", pageParam }` also accepts `itemsKey`/`totalKey`, and `{ kind: "cursor", pageParam }` accepts `cursorKey`/`itemsKey` to point at nonstandard response fields. The schema is just a plain TS module.
## When to hand-edit
Codegen handles ~90% of the boilerplate, but a few things should be hand-tuned:
- **Tags** — codegen only copies the spec's static operation `tags`. Richer tags — object tags like `{ type: "post", id }` or dynamic tag functions — aren't part of OpenAPI, so compose them in a sibling file to keep the generated file pristine across regenerations:
```ts
// src/api/schema.tags.ts
import { schema as base } from "./schema.generated";
export const schema = {
...base,
getPost: {
...base.getPost,
tags: ({ response }) => [{ type: "post", id: response.id }],
},
createPost: {
...base.createPost,
invalidatesTags: ["posts"],
},
} as const;
```
- **Custom error types.** Codegen doesn't emit anything about error responses. Pair the schema with your own `parseError` in `createApiClient` to surface `ApiProblem`/RFC 7807 fields — see [Error handling](https://use-q.dev/docs/core/error-handling.md).
- **Renaming routes.** If an `operationId` is missing or ugly, rename the keys in the generated file. All the type information lives in each route's `satisfies RouteDefinition<…>` clause, so it moves with the key.
## Running as part of your build
A common setup:
```json
// package.json
{
"scripts": {
"codegen": "use-q-codegen --input ./openapi.json --output ./src/api/schema.generated.ts",
"prebuild": "pnpm codegen"
}
}
```
For monorepos:
### Put the OpenAPI spec in a shared package
Keep the spec in a `packages/api-spec` package so it's versioned alongside your code.
### Generate the schema into its own package
Point `--output` at a shared `packages/api-schema` package.
### Import the schema from consumers
Import `schema` from `@org/api-schema` in both web and CLI consumers.
See [Monorepo usage](https://use-q.dev/docs/guides/monorepo-usage.md) for a full walkthrough.
## Programmatic API
For build scripts or codemods, you can call the generator directly:
```ts
import { generate } from "@use-q/api-client-codegen";
const source = await generate({
input: "./openapi.json",
output: "./src/api/schema.ts", // optional — omit to skip writing
baseUrl: "https://api.example.com", // optional
});
```
`generate` parses the spec, renders the schema module, writes it to `output` if provided (creating parent directories as needed), and resolves with the generated source string either way.
## Formatting
The emitter runs the output through Prettier using your project's resolved config (or Prettier's defaults if none is found). Both the CLI and the programmatic API always format.
---
# createApiClient
> createApiClient(schema, options) — turn a schema into hooks, utilities, and a typed cache.
`createApiClient` is the entry point for the React layer. Given a `Schema`, it returns a bundle of hooks and helpers that share one `QueryClient`, one fetcher, and one `TagRegistry`.
This is the query abstraction TkDodo argues for in [Creating Query Abstractions](https://tkdodo.eu/blog/creating-query-abstractions) and [The Query Options API](https://tkdodo.eu/blog/the-query-options-api): one place owns `queryKey` + `queryFn` (and the types). You still use TanStack Query — you just don't re-create that layer in every app.
```ts
import { createApiClient } from "@use-q/api-client-react";
import { schema } from "./schema";
export const api = createApiClient(schema, {
baseUrl: "https://api.example.com",
});
```
The schema is passed as a runtime value (not just a type parameter) because the hooks read `tags`, `invalidatesTags`, and `pagination` from it at runtime. Inference is automatic — no explicit generic needed.
## Options
`createApiClient` accepts everything `createFetcher` does, plus an optional `queryClient`:
```ts
interface CreateApiClientOptions {
baseUrl: string;
headers?: HeadersInit | (() => HeadersInit | Promise);
fetch?: typeof fetch;
parseError?: (input: { response: Response; data: unknown }) => unknown;
onError?: (error: unknown) => void;
queryClient?: QueryClient;
}
```
See [Core options](https://use-q.dev/docs/api-reference/create-fetcher-options.md) for the fetcher-side fields. The React-only `queryClient` is documented in [Bring your own QueryClient](https://use-q.dev/docs/guides/byo-query-client.md).
## Returned shape
```ts
interface ApiClient {
// Hooks
useQ: /* useQ bound to TSchema */;
useM: /* useM bound to TSchema */;
useInfiniteQ: /* useInfiniteQ bound to TSchema */;
useSuspenseQ: /* useSuspenseQ bound to TSchema */;
useQClient: /* useQClient bound to TSchema */;
// Plumbing
fetcher: FetcherInstance;
queryClient: QueryClient;
queryKeys: QueryKeysFactory;
schema: TSchema;
isApiError: typeof isApiError;
// Internal — exposed for advanced cases
_tagRegistry: TagRegistry;
}
```
Note that `ApiErrorBoundary` is not part of this object — it's a component exported from the package itself:
```ts
import { ApiErrorBoundary } from "@use-q/api-client-react";
```
The hook references are **stable across renders** — they're created once in the closure, so it's safe to destructure them at module scope:
```ts
export const { useQ, useM, useInfiniteQ, useQClient } = api;
```
## Recommended pattern: `src/api/client.ts`
Create the client in one place and re-export hooks so consumers never see the bare `api.useQ(...)` form:
```ts
// src/api/client.ts
import { createApiClient } from "@use-q/api-client-react";
import { schema } from "./schema";
export const api = createApiClient(schema, {
baseUrl: import.meta.env.VITE_API_BASE_URL,
headers: () => ({
Authorization: `Bearer ${tokenStore.get() ?? ""}`,
}),
onError: (err) => {
if (api.isApiError(err) && err.status === 401) tokenStore.clear();
},
});
export const {
useQ,
useM,
useInfiniteQ,
useSuspenseQ,
useQClient,
queryClient,
} = api;
```
Components then read hooks with zero ceremony:
```tsx
import { useQ } from "@/api/client";
function PostList() {
const { data } = useQ("listPosts", { params: { facilityId: "f1" } });
return
{data?.map((p) =>
{p.title}
)}
;
}
```
## Wrapping your app
`createApiClient` constructs its own `QueryClient`, which you pass to `QueryClientProvider`:
```tsx
import { QueryClientProvider } from "@tanstack/react-query";
import { api } from "@/api/client";
export function Root({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
```
If you'd rather share a `QueryClient` with something else (e.g. another `createApiClient` instance, or a non-`use-q` query), pass it via `options.queryClient`:
```ts
import { QueryClient } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, refetchOnWindowFocus: false },
},
});
const api = createApiClient(schema, {
baseUrl: "https://api.example.com",
queryClient,
});
```
See [BYO QueryClient](https://use-q.dev/docs/guides/byo-query-client.md) for the full discussion.
## Normalizing error payloads with `parseError`
`parseError` receives the failed `Response` plus its already-parsed body and returns the value stored on `ApiError.data`. (Return an `ApiError` instance to replace the error entirely.)
```ts
interface ApiProblem {
type: string;
title: string;
detail: string;
}
export const api = createApiClient(schema, {
baseUrl: "https://api.example.com",
parseError: ({ response, data }): ApiProblem => {
const problem = data as Partial | null;
return {
type: problem?.type ?? "about:blank",
title: problem?.title ?? response.statusText,
detail: problem?.detail ?? "",
};
},
});
```
Hooks surface failures as `ApiError` — narrow with `isApiError` and read the normalized payload from `error.data`. See [Error handling](https://use-q.dev/docs/core/error-handling.md).
## Multiple clients
You can call `createApiClient` more than once per app — useful for unrelated APIs (e.g. internal API + public API, or distinct microservices):
```ts
export const internal = createApiClient(internalSchema, {
baseUrl: "https://internal.example.com",
queryClient,
});
export const public_ = createApiClient(publicSchema, {
baseUrl: "https://public.example.com",
queryClient,
});
```
> **Warning:**
> Each `createApiClient` instance owns its own `TagRegistry`. Sharing a `QueryClient` between clients still keeps tag invalidation scoped to the client that owns the tag — a `createPost` mutation in `internal` won't invalidate `public_` queries (which is what you want).
## Next
- [`useQ`](https://use-q.dev/docs/react/use-q.md) — querying
- [`useM`](https://use-q.dev/docs/react/use-m.md) — mutating
- [`useQClient`](https://use-q.dev/docs/react/use-q-client.md) — manual cache control
- [``](https://use-q.dev/docs/react/api-error-boundary.md) — suspense error handling
---
# useQ
> Fetch a route as a typed query. select, enabled, staleTime, refetchInterval, and more.
`useQ` is the read-side hook. It's a thin, typed wrapper around `useQuery` from TanStack Query — same return shape, same options, with parameters constrained to a schema route. Types come from the `RouteDefinition`, not from `useQuery` generics ([React Query and TypeScript](https://tkdodo.eu/blog/react-query-and-type-script)).
```tsx
const { data, isLoading, error, refetch } = useQ("listPosts", {
params: { facilityId: "f1" },
searchParams: { search: "" },
});
// ^? data: Post[] | undefined
```
## Signature
```ts
function useQ(
routeId: RouteId,
input?: RouteInput,
options?: UseQOptions>,
): UseQueryResult>;
```
Three positional arguments:
1. `routeId` — a key of your schema.
2. `input` — the request input: `{ params?, searchParams? }`. `params` fills the `{placeholder}` segments of the route's `path`; `searchParams` becomes the query string.
3. `options` — any TanStack `useQuery` options except `queryKey` and `queryFn` (the hook owns those).
## Input
For a route with path params and/or search params, pass them via `input`:
```ts
useQ("getPost", {
params: { facilityId: "f1", postId: "p1" },
});
useQ("listPosts", {
params: { facilityId: "f1" },
searchParams: { search: "react" }, // optional
});
```
A route with no input can omit the argument entirely: `useQ("listFacilities")`.
## `UseQOptions`
Options are the third argument — a separate object from the input:
```ts
type UseQOptions = Omit<
UseQueryOptions,
"queryKey" | "queryFn"
>;
```
That means everything `useQuery` supports is available: `enabled`, `select`, `staleTime`, `gcTime`, `refetchInterval`, `refetchOnWindowFocus`, `refetchOnReconnect`, `retry`, `placeholderData`, `initialData`, `meta`, and so on.
### `enabled` — conditional fetching
Guard a query on data from another query, route params, or feature flags:
```tsx
function PostDetail({ facilityId, postId }: { facilityId: string; postId?: string }) {
const post = useQ(
"getPost",
{ params: { facilityId, postId: postId! } },
{ enabled: Boolean(postId) },
);
if (!postId) return
Pick a post
;
return
{post.data?.title}
;
}
```
### `select` — project the response
Avoid re-rendering components that only need a slice of the response. Transform with `select`, not inside the query function — that's [TkDodo's data-transformation](https://tkdodo.eu/blog/react-query-data-transformations) and [render-optimization](https://tkdodo.eu/blog/react-query-render-optimizations) advice, taken further in [selectors, supercharged](https://tkdodo.eu/blog/react-query-selectors-supercharged). `select` is memoized by TanStack Query, so the result is referentially stable as long as the selector returns the same value.
```tsx
const titles = useQ(
"listPosts",
{ params: { facilityId: "f1" } },
{ select: (posts) => posts.map((p) => p.title) },
);
// titles.data is now string[]
```
A common pattern: derive a normalized lookup map without forcing the rest of the app to rebuild it.
```tsx
const postsById = useQ(
"listPosts",
{ params: { facilityId: "f1" } },
{ select: (posts) => Object.fromEntries(posts.map((p) => [p.id, p])) },
);
```
### `staleTime` — when to consider data fresh
Defaults to `0` (always stale → refetch on mount / focus). Increase it for slow-changing data:
```tsx
const settings = useQ(
"getSettings",
{ params: { facilityId: "f1" } },
{ staleTime: 5 * 60_000 },
);
```
### `refetchInterval` — polling
```tsx
const jobStatus = useQ(
"getJobStatus",
{ params: { jobId } },
{
refetchInterval: (query) =>
query.state.data?.status === "running" ? 1000 : false,
},
);
```
Pass a function to stop polling once a terminal state is reached.
### `refetchOnWindowFocus` / `refetchOnReconnect`
Both default to `true`. Disable for queries where stale data is acceptable but a flash of refetch would be jarring:
```tsx
useQ("getCurrentUser", undefined, {
staleTime: 60_000,
refetchOnWindowFocus: false,
});
```
### `retry`
Standard TanStack Query semantics — pass a number or a predicate. Narrow the error with `isApiError`:
```tsx
import { isApiError } from "@use-q/api-client-react";
useQ(
"getPost",
{ params: { facilityId, postId } },
{
retry: (count, error) => {
if (isApiError(error) && error.status === 404) return false;
return count < 3;
},
},
);
```
### `placeholderData`
Render something while the real data loads. This does **not** put the value in the cache — unlike `initialData`. See [Placeholder and Initial Data](https://tkdodo.eu/blog/placeholder-and-initial-data-in-react-query) for when to use which.
```tsx
useQ(
"listPosts",
{ params: { facilityId: "f1" } },
{ placeholderData: [] as Post[] },
);
```
Or — for "keep previous data" while paginating — use `keepPreviousData` from TanStack Query:
```tsx
import { keepPreviousData } from "@tanstack/react-query";
useQ(
"listPosts",
{ params: { facilityId }, searchParams: { page } },
{ placeholderData: keepPreviousData },
);
```
### `initialData`
Hydrate the cache from a server-fetched payload (RSC / loader):
```tsx
useQ(
"listPosts",
{ params: { facilityId: "f1" } },
{ initialData: () => loaderData.posts },
);
```
See [SSR & loaders](https://use-q.dev/docs/guides/ssr-and-loaders.md) for the full pattern.
## Returned shape
`useQ` returns TanStack Query's `UseQueryResult` — `{ data, error, isPending, isLoading, isFetching, isError, isSuccess, refetch, … }`. Prefer `isPending` / `isError` / `isSuccess` over treating `isLoading` as the only gate ([status checks](https://tkdodo.eu/blog/status-checks-in-react-query)). Failures thrown by the fetcher are `ApiError` instances; use `isApiError(error)` to narrow before reading `status`, `data`, etc. See [Error handling](https://use-q.dev/docs/core/error-handling.md).
## Tag registration
After data arrives, `useQ` resolves the route's `tags` (static array or `({ response, params }) => Tag[]` function) and registers them with the client's `TagRegistry` — once per query key + response identity. On unmount, the registration is removed. This is what lets mutations and `useQClient().invalidateTag` find the query later. See [Tag invalidation](https://use-q.dev/docs/guides/tag-invalidation.md).
## Query keys
Every `useQ` call uses the key:
```ts
["api", method, resolvedPath, sortedSearchParams];
// e.g. ["api", "GET", "/facilities/f1/posts", { search: "" }]
```
This shape makes prefix invalidation natural. See [Query keys](https://use-q.dev/docs/guides/query-keys.md).
## Cancellation
`useQ` already passes an `AbortSignal` to the fetcher when the component unmounts or the key changes — that's [the query function context](https://tkdodo.eu/blog/leveraging-the-query-function-context) (`QueryFunctionContext.signal`). A custom `fetch` implementation receives it via `RequestInit.signal`. Don't pass `signal` manually; let TanStack Query own it.
> **Tip:**
> Want to render inside ``? Use [`useSuspenseQ`](https://use-q.dev/docs/react/use-suspense-q.md) instead — same signature, suspense semantics.
---
# useM
> Type-safe mutations with optimistic updates, multi-target snapshots, and automatic tag invalidation.
`useM` is the mutation hook. It wraps `useMutation` with route-aware typing, automatic tag invalidation, and a structured optimistic-update API — [Mastering Mutations](https://tkdodo.eu/blog/mastering-mutations-in-react-query) as defaults.
```tsx
const createPost = useM("createPost");
createPost.mutate({
params: { facilityId },
body: { title: "Hello", body: "World" },
});
```
## Signature
```ts
function useM(
routeId: RouteId,
options?: UseMOptions,
): UseMutationResult<
RouteResponse,
Error,
{ params?; body?; searchParams? },
{ snapshots: Array<{ key: readonly unknown[]; previous: unknown }> }
>;
```
All request data — path `params`, `body`, and `searchParams` — is passed as the variables object when you call `mutate` / `mutateAsync`. Nothing request-specific is fixed at hook creation time.
## Basic mutation
```tsx
function NewPost({ facilityId }: { facilityId: string }) {
const createPost = useM("createPost");
return (
);
}
```
Because the route's `invalidatesTags` match `tags` registered by read routes, any `useQ("listPosts", …)` currently tagged `"posts"` refetches automatically after the mutation settles.
## Optimistic updates
`useM` accepts an `optimisticUpdates` array. Each entry says: "before the request goes out, update this cached query as if the mutation already succeeded." If the request fails, every snapshot rolls back.
Each entry has a `target` (which queries to touch) and an `updater`:
```ts
optimisticUpdates?: ReadonlyArray<{
target:
| { routeId: keyof TSchema & string; input?: RouteInput }
| { tags: ReadonlyArray };
updater: (previous: unknown, variables: { params?; body? }) => unknown;
}>;
```
A `routeId` target resolves to that route's exact query key (built from `input`). A `tags` target resolves via the `TagRegistry` to every currently-registered query key carrying any of those tags.
### Single target
```tsx
const updatePost = useM("updatePost", {
optimisticUpdates: [
{
target: { routeId: "getPost", input: { params: { facilityId, postId } } },
updater: (prev, { body }) => prev && { ...(prev as Post), ...body },
},
],
});
```
The `updater` receives the previous cached value and the `{ params, body }` from the variables passed to `mutate`. Return the next value (or `prev` to do nothing).
### Multiple targets
A mutation often touches several caches. Each target is applied independently with its own snapshot:
```tsx
const updatePost = useM("updatePost", {
optimisticUpdates: [
{
// Detail page
target: { routeId: "getPost", input: { params: { facilityId, postId } } },
updater: (prev, { body }) => prev && { ...(prev as Post), ...body },
},
{
// List view
target: { routeId: "listPosts", input: { params: { facilityId } } },
updater: (prev, { body }) =>
(prev as Post[] | undefined)?.map((p) =>
p.id === postId ? { ...p, ...body } : p,
),
},
],
});
```
### List insert / delete
```tsx
const createPost = useM("createPost", {
optimisticUpdates: [
{
target: { routeId: "listPosts", input: { params: { facilityId } } },
updater: (prev, { body }) => [
{
id: `temp-${crypto.randomUUID()}`,
facilityId,
createdAt: new Date().toISOString(),
...(body as CreatePostInput),
},
...((prev as Post[] | undefined) ?? []),
],
},
],
});
const deletePost = useM("deletePost", {
optimisticUpdates: [
{
target: { routeId: "listPosts", input: { params: { facilityId } } },
updater: (prev, { params }) =>
(prev as Post[] | undefined)?.filter((p) => p.id !== params?.postId),
},
],
});
```
### Rollback semantics
Under the hood, `useM` does this for every resolved target key:
1. `cancelQueries(key)` to stop any in-flight refetch from clobbering the optimistic value.
2. `getQueryData(key)` to snapshot the current state.
3. `setQueryData(key, updater(prev, { params, body }))` to apply the optimistic update.
4. On error, restore every snapshot.
5. On settle, run tag invalidation so any drift is reconciled.
The snapshots are exposed as the mutation context:
```ts
createPost.mutate(vars, {
onError: (err, vars, ctx) => {
// ctx is { snapshots }; useM already used it for rollback.
console.error("rolled back", ctx?.snapshots.length, "snapshots");
},
});
```
## `additionalInvalidatesTags`
Sometimes a mutation needs to invalidate caches that aren't in the schema's `invalidatesTags` (e.g. cross-cutting "feed" views). Add them at the call site:
```tsx
const createPost = useM("createPost", {
additionalInvalidatesTags: ["feed", { type: "post" }],
});
```
`additionalInvalidatesTags` are unioned with the schema's `invalidatesTags` (static array or `({ response, variables }) => Tag[]` function). Both run when the mutation settles.
## Hook-level and per-call options
`UseMOptions` extends TanStack's `useMutation` options minus `mutationFn` — so `onMutate`, `onSuccess`, `onError`, `onSettled`, `retry`, `retryDelay`, and `mutationKey` all work. The hook wraps `onMutate`, `onError`, and `onSettled` internally for the optimistic/invalidations machinery, then calls yours after its own work.
`mutate` also accepts the standard per-call options:
```tsx
createPost.mutate(
{ params: { facilityId }, body: { title, body } },
{
onSuccess: (post) => router.push(`/posts/${post.id}`),
onError: (err) => toast.error(err.message),
onSettled: () => analytics.track("post_create_attempt"),
},
);
```
These run _in addition to_ the ones declared at the hook level — useful for one-off behaviors.
## Tag chaining recap
| Where you declare it | What it does |
| --- | --- |
| Route `tags` (in schema, on a read route) | Labels the cache entry so mutations can find it. |
| Route `invalidatesTags` (in schema, on a write route) | Invalidates queries registered with matching tags when the mutation settles. |
| `useM` `additionalInvalidatesTags` (call-site) | Extra tags to invalidate for this particular hook. |
Tags are `string | { type: string; id?: string | number }` and match by **exact identity** after normalization — `{ type: "post", id: "p1" }` matches only queries registered with `{ type: "post", id: "p1" }` (or the equivalent string `"post:p1"`), and `"posts"` matches only `"posts"`. To support both broad and narrow invalidation, register both from the read route:
```ts
tags: ({ response }) => ["posts", { type: "post", id: response.id }],
```
See [Tag invalidation](https://use-q.dev/docs/guides/tag-invalidation.md) for the deep dive.
> **Tip:**
> Need to invalidate or update queries imperatively outside a mutation? Use [`useQClient`](https://use-q.dev/docs/react/use-q-client.md).
---
# useInfiniteQ
> Infinite/paginated queries with schema-driven pageParam, getNextPageParam, and aggregated pages.
`useInfiniteQ` is `useQ`'s big sibling for paginated routes. It only works with routes whose `RouteDefinition` declares a `pagination` block — calling it on a route without one throws immediately with a clear error message.
The hook is a typed wrapper around TanStack's infinite queries — see [How Infinite Queries work](https://tkdodo.eu/blog/how-infinite-queries-work) for the cache shape (`{ pages, pageParams }`) and why `getNextPageParam` / `maxPages` behave the way they do.
## A paginated route
```ts
// src/api/schema.ts
import type { RouteDefinition } from "@use-q/api-client";
export const schema = {
listPosts: {
method: "GET",
path: "/facilities/{facilityId}/posts",
pagination: {
kind: "page-number",
pageParam: "page",
itemsKey: "items", // default "items"
totalKey: "total", // default "total"
},
tags: ["posts"],
} satisfies RouteDefinition<
{ facilityId: string },
{ page?: number; limit?: number; search?: string },
never,
{ items: Post[]; total: number }
>,
} as const;
```
The `pagination` block tells `useInfiniteQ` two things:
1. Which `searchParams` key carries the page value (`pageParam`) — the hook injects the current page param there on every fetch.
2. How to find items and total/next-cursor in the response (`itemsKey` + `totalKey`, or `cursorKey`), so it can derive a default `getNextPageParam`.
## Using it
With a `page-number` route, the defaults kick in automatically: `initialPageParam` is `1`, and the next page is `lastPageParam + 1` until `currentPage * items.length >= total`.
```tsx
import { useInfiniteQ } from "@/api/client";
function PostList({ facilityId }: { facilityId: string }) {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQ("listPosts", {
params: { facilityId },
searchParams: { limit: 20 },
});
if (isLoading) return
Loading…
;
return (
<>
{data?.pages.flatMap((page) =>
page.items.map((post) => {post.title}),
)}
{hasNextPage && (
)}
>
);
}
```
If the defaults don't fit your response shape, override `initialPageParam` / `getNextPageParam` in the third argument:
```tsx
useInfiniteQ(
"listPosts",
{ params: { facilityId }, searchParams: { limit: 20 } },
{
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
const fetched = allPages.reduce((sum, p) => sum + p.items.length, 0);
return fetched < lastPage.total ? allPages.length + 1 : undefined;
},
},
);
```
## Cursor pagination
```ts
listFeed: {
method: "GET",
path: "/feed",
pagination: {
kind: "cursor",
pageParam: "cursor",
cursorKey: "nextCursor", // default "nextCursor"
itemsKey: "items", // default "items"
},
} satisfies RouteDefinition<
never,
{ cursor?: string; limit?: number },
never,
{ items: Post[]; nextCursor: string | null }
>,
```
```tsx
const feed = useInfiniteQ("listFeed", { searchParams: { limit: 25 } });
```
For `cursor` routes the defaults are: `initialPageParam` is `null` (the first request sends no cursor), and the next page param is `lastPage[cursorKey] ?? null`. When it resolves to `null`, `hasNextPage` becomes `false`.
## `pages` aggregation
`data.pages` is an array of raw responses (one per fetched page). To render a flat list, `.flatMap` through them:
```tsx
{data?.pages.flatMap((p) => p.items).map((post) => (
{post.title}
))}
```
If you'd rather expose a derived shape to the component, use `select`:
```tsx
const posts = useInfiniteQ(
"listPosts",
{ params: { facilityId } },
{
select: (data) => ({
posts: data.pages.flatMap((p) => p.items),
total: data.pages[0]?.total ?? 0,
}),
},
);
posts.data?.posts; // Post[]
posts.data?.total; // number
```
## Scroll-trigger pattern
A common UX: load more whenever a sentinel scrolls into view. Combine with `IntersectionObserver`:
```tsx
import { useEffect, useRef } from "react";
function PostList({ facilityId }: { facilityId: string }) {
const sentinelRef = useRef(null);
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQ("listPosts", { params: { facilityId } });
useEffect(() => {
const el = sentinelRef.current;
if (!el || !hasNextPage) return;
const obs = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && !isFetchingNextPage) {
void fetchNextPage();
}
});
obs.observe(el);
return () => obs.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<>
{data?.pages.flatMap((p) =>
p.items.map((post) => {post.title}),
)}
>
);
}
```
## Options
The third argument accepts everything TanStack's `useInfiniteQuery` does (minus `queryKey`/`queryFn`), with `initialPageParam` and `getNextPageParam` made **optional** because they're usually derived from the route's `pagination`:
```ts
type UseInfiniteQOptions = Omit<
UseInfiniteQueryOptions, QueryKey, TPageParam>,
"queryKey" | "queryFn" | "initialPageParam" | "getNextPageParam"
> & {
initialPageParam?: TPageParam;
getNextPageParam?: (
lastPage: TData,
allPages: TData[],
lastPageParam: TPageParam,
) => TPageParam | undefined | null;
};
```
### `maxPages`
Cap how many pages stay in memory; useful for very long lists where dropping older pages is OK:
```tsx
useInfiniteQ("listPosts", { params: { facilityId } }, {
maxPages: 10,
getPreviousPageParam: (_first, _all, firstPageParam) =>
typeof firstPageParam === "number" && firstPageParam > 1
? firstPageParam - 1
: undefined,
});
```
(TanStack Query requires `getPreviousPageParam` when using `maxPages`, so it can re-fetch dropped pages when scrolling back.)
## Runtime guard
If the route has no `pagination` block, the hook throws as soon as it runs:
```ts
useInfiniteQ("getPost", { params: { facilityId, postId } });
// Error: useInfiniteQ: route "getPost" has no `pagination` declaration in the schema.
```
> **Tip:**
> Want suspense semantics? Wrap your component in `` and use `useSuspenseInfiniteQuery` from TanStack Query directly against `api.queryClient` — `useInfiniteQ` follows the standard (non-suspense) shape, so the loading/error UI lives inside the component.
---
# useSuspenseQ
> Suspense-friendly query hook for use with and .
`useSuspenseQ` is the suspense counterpart of [`useQ`](https://use-q.dev/docs/react/use-q.md) — same three-argument signature (`routeId`, `input?`, `options?`). It throws a promise while loading and an `ApiError` on failure, so the loading/error UI lives in the boundary instead of the component ([error handling with boundaries](https://tkdodo.eu/blog/react-query-error-handling), [status checks](https://tkdodo.eu/blog/status-checks-in-react-query)):
```tsx
function PostList({ facilityId }: { facilityId: string }) {
const { data } = useSuspenseQ("listPosts", {
params: { facilityId },
});
return (
{data.map((post) => (
{post.title}
))}
);
}
```
Note `data` is _non-nullable_ — by the time your component renders, the data has resolved.
## With `` + ``
```tsx
import { Suspense } from "react";
import { ApiErrorBoundary } from "@use-q/api-client-react";
function PostsPage({ facilityId }: { facilityId: string }) {
return (
(
{error.status} — {error.message}
)}
>
Loading posts…}>
);
}
```
The `fallback` receives a single object — `{ error, reset }` — and `error` is already narrowed to `ApiError`: the boundary re-throws anything that isn't one. Order matters: the error boundary must wrap ``. If they swap, suspension itself counts as an error.
## RSC-friendly pattern
`useSuspenseQ` works in a streaming React Server Components setup. The pattern:
1. Pre-fetch on the server using the raw fetcher.
2. Hydrate the client cache.
3. Render the client component, which immediately suspends and resolves with the hydrated data.
```tsx
// app/posts/page.tsx (RSC)
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { api } from "@/api/client";
import { PostsPage } from "./PostsPage";
export default async function Page() {
const queryClient = api.queryClient;
await queryClient.prefetchQuery({
queryKey: api.queryKeys.listPosts({ params: { facilityId: "f1" } }),
queryFn: ({ signal }) =>
api.fetcher.fetch("/facilities/{facilityId}/posts", {
method: "GET",
params: { facilityId: "f1" },
signal,
}),
});
return (
);
}
```
```tsx
// app/posts/PostsPage.tsx — client
"use client";
import { Suspense } from "react";
import { useSuspenseQ } from "@/api/client";
import { ApiErrorBoundary } from "@use-q/api-client-react";
function PostList({ facilityId }: { facilityId: string }) {
const { data } = useSuspenseQ("listPosts", { params: { facilityId } });
return data.map((p) => {p.title});
}
export function PostsPage({ facilityId }: { facilityId: string }) {
return (
Couldn't load.
}>
Loading…}>
);
}
```
See [SSR & loaders](https://use-q.dev/docs/guides/ssr-and-loaders.md) for the full hydration story.
## When to prefer `useQ` instead
`useSuspenseQ` is great when:
- You're already using suspense boundaries for code-splitting.
- The component would otherwise be a tangle of `if (isLoading) return …; if (error) return …;` branches.
It's the wrong fit when:
- You need fine-grained access to `isFetching` or `isRefetching` inside the component.
- You want the component to render its own skeleton inline rather than bubbling to a boundary.
- The data is optional/conditional (use `useQ` with `enabled`).
## Options and returned shape
The third argument accepts TanStack's `useSuspenseQuery` options minus `queryKey`/`queryFn` — so `select`, `staleTime`, `gcTime`, `retry`, and friends all work.
`useSuspenseQ` returns the same object as TanStack Query's `useSuspenseQuery`: `{ data, error, isFetching, refetch, … }`. The big differences vs. `useQ`:
- `data` is the response type (not `T | undefined`).
- There's no `isLoading` — the suspense boundary handles it.
- `error` is always `null` while the component is mounted (a real error throws up to the boundary).
> **Warning:**
> `useSuspenseQ` doesn't support `enabled: false`. If the query should be optional, gate the rendering of the suspending component itself:
>
> ```tsx
{postId && }
```
---
# useQClient
> Imperative cache control — invalidate by tag or key, prefetch, setData, updateData, and more.
`useQClient` is the imperative escape hatch. It returns a typed wrapper around the underlying `QueryClient` so you can invalidate, prefetch, and edit cache entries by route id (with full type inference).
That's [seeding the query cache](https://tkdodo.eu/blog/seeding-the-query-cache) without assembling keys by hand — `prefetch`, `setData`, and `updateData` all go through the same `queryKeys` factory `useQ` uses.
```tsx
import { useQClient } from "@/api/client";
const qc = useQClient();
qc.invalidateTag({ type: "post", id: "p1" });
```
The returned object is stable across renders — it's safe to put in dependency arrays.
## API
```ts
interface QClient {
invalidateTag(tags: Tag | ReadonlyArray): Promise;
invalidate(
target: ReadonlyArray | { prefix: ReadonlyArray },
): Promise;
invalidateAll(): Promise;
setData(
routeId: RouteId,
input: RouteInput | undefined,
data: RouteResponse,
): void;
updateData(
routeId: RouteId,
input: RouteInput | undefined,
updater: (
prev: RouteResponse | undefined,
) => RouteResponse,
): void;
prefetch(
routeId: RouteId,
input?: RouteInput,
): Promise;
}
```
## `invalidateTag`
Invalidate every cached query registered against the given tag(s) — pass one tag or an array. This resolves through the same `TagRegistry` that `useM` uses for `invalidatesTags`:
```ts
qc.invalidateTag({ type: "post", id: "p1" });
// Invalidates every query registered with the tag { type: "post", id: "p1" }.
qc.invalidateTag("posts");
// Invalidates every query registered with the string tag "posts".
qc.invalidateTag(["posts", { type: "post", id: "p1" }]);
// Union of both.
```
Tags match by exact identity (`{ type, id }` normalizes to `"type:id"`) — a bare `{ type: "post" }` does **not** match id-carrying tags, so register both broad and narrow tags on read routes if you need both granularities.
Use this after a non-`useM` side effect (e.g. an out-of-band server-sent event):
```tsx
useEffect(() => {
const es = new EventSource("/events");
es.addEventListener("post-changed", (msg) => {
const { postId } = JSON.parse(msg.data);
void qc.invalidateTag({ type: "post", id: postId });
});
return () => es.close();
}, [qc]);
```
## `invalidate(target)` — exact vs prefix
Invalidate by query key. Pass a key array for an exact match, or wrap it in `{ prefix: … }` to invalidate every key starting with it. Build keys with the `api.queryKeys` factory instead of writing them by hand:
```ts
import { api } from "@/api/client";
// Exact — only this query
void qc.invalidate(
api.queryKeys.getPost({ params: { facilityId: "f1", postId: "p1" } }),
);
// Prefix — every query for this method + path
void qc.invalidate({
prefix: ["api", "GET", "/facilities/f1/posts"],
});
```
Under the hood, the exact form uses TanStack Query's `exact: true` filter and the prefix form uses `exact: false` against the query key shape `["api", METHOD, resolvedPath, sortedSearchParams]`. See [Query keys](https://use-q.dev/docs/guides/query-keys.md).
## `invalidateAll`
Nuke the whole cache. Useful after a logout or a tenant switch:
```ts
function logout() {
tokenStore.clear();
void qc.invalidateAll();
router.push("/login");
}
```
## `setData` — replace a cache entry
Synchronously write a fully-typed value into the cache for a given route + input:
```ts
qc.setData(
"getPost",
{ params: { facilityId: "f1", postId: "p1" } },
{ id: "p1", facilityId: "f1", title: "Edited", body: "…", createdAt: "…" },
);
```
This is also how you hydrate from a loader/server payload — see [SSR & loaders](https://use-q.dev/docs/guides/ssr-and-loaders.md).
## `updateData` — patch a cache entry
`updateData` is the functional cousin: instead of providing the whole next value, you provide an updater.
```ts
qc.updateData(
"getPost",
{ params: { facilityId: "f1", postId: "p1" } },
(prev) => (prev ? { ...prev, title: "Edited" } : prev),
);
```
Common after a non-list response patches one item but the list cache is also stale.
## `prefetch`
Warm the cache for a route, typically on hover or route preload:
```tsx
function PostLink({ post, facilityId }: { post: Post; facilityId: string }) {
const qc = useQClient();
return (
void qc.prefetch("getPost", {
params: { facilityId, postId: post.id },
})
}
>
{post.title}
);
}
```
`prefetch` takes only the route id and input. Freshness follows the `QueryClient`'s configured `staleTime` — if you need a specific window for prefetched data, set it in the `QueryClient` defaults (see [BYO QueryClient](https://use-q.dev/docs/guides/byo-query-client.md)) or drop down to `api.queryClient.prefetchQuery` directly.
## Imperative use outside a component
If you need cache control outside React (e.g. a global `logout` function), reach for `api.queryClient` directly and skip `useQClient`:
```ts
import { api } from "@/api/client";
export function logout() {
void api.queryClient.invalidateQueries();
api.queryClient.clear();
}
```
The `queryClient` from `createApiClient` is the same instance backing every hook in your app.
> **Tip:**
> `useQClient` is the only typed surface that knows about your `Schema`. For ad-hoc TanStack Query operations (`getQueriesData` filters, manual key arrays, etc.), use `api.queryClient` directly — but you'll lose route-level type safety on those calls.
---
# ApiErrorBoundary
> A typed React error boundary for use with useSuspenseQ. fallback signature, reset semantics, and ApiError narrowing.
`` is a small class-based error boundary that catches `ApiError`s thrown during render and lets you reset back to a clean state. Anything that is *not* an `ApiError` is re-thrown to the next boundary up the tree — so inside the fallback, the error is always a typed `ApiError`.
It's the partner to `useSuspenseQ` / `useSuspenseInfiniteQuery`, and the error-boundary half of [TkDodo's error-handling advice](https://tkdodo.eu/blog/react-query-error-handling). Throwing a `Promise` (suspending) propagates to ``; throwing an `ApiError` propagates here.
## Basic usage
```tsx
import { Suspense } from "react";
import { ApiErrorBoundary } from "@use-q/api-client-react";
import { useSuspenseQ } from "@/api/client";
function PostList({ facilityId }: { facilityId: string }) {
const { data } = useSuspenseQ("listPosts", { params: { facilityId } });
return data.map((p) => {p.title});
}
export function Page({ facilityId }: { facilityId: string }) {
return (
(
{error.status} — {error.message}
)}
>
Loading posts…}>
);
}
```
## Props
```ts
interface ApiErrorBoundaryProps {
children: React.ReactNode;
fallback: (state: { error: ApiError; reset: () => void }) => React.ReactNode;
onError?: (error: unknown, info: React.ErrorInfo) => void;
}
```
### `fallback`
Called whenever an `ApiError` is caught during render. It receives a **single object** with the typed `error` and a `reset` function — no `isApiError` check needed, because non-API errors never reach the fallback (they're re-thrown to the next boundary).
Branch on `error.status` and read the (parsed) error payload from `error.data`:
```tsx
{
if (error.status === 404) return ;
if (error.status === 403) return ;
return (
);
}}
>
{/* … */}
```
If you configured `parseError` on the client, `error.data` holds whatever it returned — see [Error handling](https://use-q.dev/docs/core/error-handling.md).
### `onError`
A side-effecting hook that fires once per caught error (API or not), with React's `ErrorInfo` as the second argument. Use it for telemetry:
```tsx
{
Sentry.captureException(err, { extra: { componentStack: info.componentStack } });
}}
fallback={({ error, reset }) => }
>
```
Note `onError` fires for non-API errors too — right before the boundary re-throws them to the next boundary up.
## `reset()` semantics
`reset()` does exactly one thing: it clears the boundary's internal error state, causing it to re-render its `children`.
It does **not** automatically refetch. The next render will resume normally — if the underlying query is still in an errored state, the suspense child will throw again immediately. The typical pattern is to pair `reset` with a cache invalidation. Since `fallback` is a plain render callback (not a component), do that in a small fallback component that can call hooks:
```tsx
import { useQClient } from "@/api/client";
import type { ApiError } from "@use-q/api-client-react";
function RetryFallback({ error, reset }: { error: ApiError; reset: () => void }) {
const qc = useQClient();
return (
);
}
}
>
{/* … */}
```
## Nesting
You can nest boundaries to make some parts of the page fail in isolation:
```tsx
}>
}>
}>
}>
```
The inner boundary catches `ApiError`s from `` only; everything else keeps rendering. And because non-API errors are re-thrown, a programming error inside `` still bubbles to the outer boundary (or your framework's root boundary) rather than being masked as an API failure.
## Typing `error.data` with `isApiError`
Inside the fallback, `error` is already an `ApiError`. To type the payload, either cast `error.data` or use the generic `isApiError` guard where you have an `unknown` error (e.g. in `onError` or in `useQ` results):
```ts
import { isApiError } from "@use-q/api-client-react";
interface ApiProblem {
type: string;
title: string;
detail: string;
}
onError={(err) => {
if (isApiError(err)) {
log.warn(`${err.status} ${err.data.title}: ${err.data.detail}`);
}
}}
```
`ApiError` carries `status`, `statusText`, `data`, `url`, and `method`.
> **Tip:**
> The boundary only holds on to errors that pass `isApiError`. Prefer letting `use-q` produce the `ApiError` for you rather than throwing strings or POJOs — anything else is re-thrown and needs its own boundary.
---
# Query keys
> How use-q structures TanStack Query keys for prefix-based invalidation and predictable caching.
Every query produced by `useQ` / `useSuspenseQ` / `useInfiniteQ` lives at a key with a strict, predictable shape:
```ts
["api", method, resolvedPath, searchParams];
```
That's it. Four positional segments — always four, even when there are no search params. Understanding this shape unlocks a lot of power, because TanStack Query's `invalidateQueries` / `getQueriesData` filters match keys by **prefix**.
This is [Effective React Query Keys](https://tkdodo.eu/blog/effective-react-query-keys) as a default: array keys, coarse-to-fine, plus a factory (`queryKeys` on the client) so you never assemble them by hand.
## The four segments
### 1. `"api"` — namespace
A constant. Lets you tell `use-q` queries apart from any other TanStack Query usage in the same `QueryClient`:
```ts
queryClient.invalidateQueries({ queryKey: ["api"] }); // every use-q query
```
### 2. `method` — HTTP method
Uppercase, matches the schema route's `method`: `"GET"`, `"POST"`, etc. In practice only `"GET"` shows up as a cached query — mutations don't get cache entries.
### 3. `resolvedPath` — path with params filled in
The route's `path` string with placeholders substituted (each value URI-encoded). For example:
| Route | `params` | Resolved path |
| --- | --- | --- |
| `/facilities/{facilityId}/posts` | `{ facilityId: "f1" }` | `/facilities/f1/posts` |
| `/facilities/{facilityId}/posts/{postId}` | `{ facilityId: "f1", postId: "p1" }` | `/facilities/f1/posts/p1` |
### 4. `searchParams` — query params object
A copy of the `searchParams` object with its keys **sorted** and `undefined` values **dropped** — not the encoded query string. TanStack Query deep-compares objects structurally, and the sorting means `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same key.
When there are no search params, the segment is an empty object `{}` — never missing:
```ts
useQ("listPosts", { params: { facilityId: "f1" } });
// → ["api", "GET", "/facilities/f1/posts", {}]
useQ("listPosts", {
params: { facilityId: "f1" },
searchParams: { search: undefined },
});
// → ["api", "GET", "/facilities/f1/posts", {}]
```
Because `undefined` values are stripped before the key is built, missing optional params don't fragment the cache.
## Why this shape?
### Prefix invalidation
Because keys are positional arrays, you can invalidate at several granularities:
```ts
// Everything use-q
queryClient.invalidateQueries({ queryKey: ["api"] });
// Every GET
queryClient.invalidateQueries({ queryKey: ["api", "GET"] });
// listPosts for facility f1, any searchParams
queryClient.invalidateQueries({
queryKey: ["api", "GET", "/facilities/f1/posts"],
});
// listPosts for facility f1, search === "" only
queryClient.invalidateQueries({
queryKey: ["api", "GET", "/facilities/f1/posts", { search: "" }],
exact: true,
});
```
> **Warning:**
> Prefix matching is **per-segment**, not per-character. `["api", "GET", "/facilities"]` does _not_ match `["api", "GET", "/facilities/f1/posts", {}]` — the resolved path is a single string segment and must match in full. To target "every list for any facility", use [tags](https://use-q.dev/docs/guides/tag-invalidation.md) instead.
The same machinery powers [`useQClient().invalidate(...)`](https://use-q.dev/docs/react/use-q-client.md) — pass an exact key array for an exact match, or `{ prefix: [...] }` for prefix matching.
### Stable + diff-friendly
Two queries with the same input produce the same key by structural equality. You don't need to memoize anything in your component — TanStack Query already does the work.
```tsx
useQ("listPosts", {
params: { facilityId: "f1" },
searchParams: { search: "" },
});
// Re-rendering 100 times yields one cache entry.
```
### Cheap to inspect in devtools
The `@tanstack/react-query-devtools` panel groups entries by key, so the shape above gives you a navigable tree: namespace → method → path → params.
## Built-in `queryKeys` factory
`createApiClient` returns a `queryKeys` factory that builds these keys for you. Each entry takes the route's input — `{ params?, searchParams? }` — and returns the full four-segment key. It's the right thing to use when you call TanStack Query's primitives manually:
```ts
import { api } from "@/api/client";
await api.queryClient.prefetchQuery({
queryKey: api.queryKeys.listPosts({
params: { facilityId: "f1" },
searchParams: { search: "" },
}),
queryFn: ({ signal }) =>
api.fetcher.fetch(api.schema.listPosts.path, {
params: { facilityId: "f1" },
searchParams: { search: "" },
signal,
}),
});
```
(For plain prefetching, [`useQClient().prefetch("listPosts", input)`](https://use-q.dev/docs/react/use-q-client.md) does all of the above in one call.)
The factory always returns the complete key, including the trailing search-params object:
```ts
api.queryKeys.listPosts({ params: { facilityId: "f1" } });
// ["api", "GET", "/facilities/f1/posts", {}]
```
If you need a prefix (to match any `searchParams`), slice off the last segment:
```ts
const key = api.queryKeys.listPosts({ params: { facilityId: "f1" } });
queryClient.invalidateQueries({ queryKey: key.slice(0, 3) });
```
## Tags vs keys: when to use which
| Use case | Tool |
| --- | --- |
| "Invalidate this exact `getPost`" | `qc.invalidate(api.queryKeys.getPost({ params }))` |
| "Invalidate `listPosts` for facility f1, any `searchParams`" | `qc.invalidate({ prefix: ["api", "GET", "/facilities/f1/posts"] })` |
| "Invalidate everything tagged `{ type: "posts", id: "f1" }`" | `qc.invalidateTag({ type: "posts", id: "f1" })` |
| "After a mutation, refresh everything the schema says it touches" | Schema `invalidatesTags` — automatic in `useM` |
Tags are loose-coupling — multiple routes can share a tag, and you don't need to know the consumer's `searchParams`. Keys are tight-coupling — you target a specific cache slot.
In practice, prefer tags for cross-cutting refreshes (after writes) and keys for surgical edits (after explicit user actions).
> **Tip:**
> When debugging "why didn't this query refetch?", inspect the actual key in devtools and compare it to your invalidation filter. A near-miss usually means the resolved path or `searchParams` differ in some subtle way (e.g. a `null` value that wasn't stripped — only `undefined` values are dropped from the key).
---
# Tag invalidation
> TagRegistry lifecycle, static vs dynamic tags, schema invalidatesTags, additionalInvalidatesTags, and why invalidation runs in onSettled.
Tags are the high-level coupling between **reads** and **writes** in a `use-q` app. A read route says "I depend on these tags." A write route says "I invalidate these tags." When the write runs, every matching read refetches.
That's [automatic query invalidation after mutations](https://tkdodo.eu/blog/automatic-query-invalidation-after-mutations) — describe relationships in the schema instead of listing query keys on every `useM`. The whole machine lives inside one `TagRegistry` per `createApiClient` instance.
## The `TagRegistry`
A `TagRegistry` is a small bidirectional map between query keys and normalized tag strings. When a `useQ` query receives data, the hook resolves the route's `tags` and registers them against the query's key. When the component unmounts, the entry is unregistered.
```ts
// Pseudocode of what happens internally
useQ("listPosts", { params: { facilityId: "f1" } });
// after data arrives:
registry.register(
["api", "GET", "/facilities/f1/posts", {}],
["posts:f1"], // resolved from route.tags, normalized to strings
);
```
A mutation's `invalidatesTags` are looked up in this registry to find matching keys, which are then invalidated through TanStack Query's `invalidateQueries`.
## The `Tag` type
```ts
type Tag = string | { type: string; id?: string | number };
```
That's the whole shape — a plain string, or an object with a `type` and an optional `id`. There are no other keys, and values are always plain data (never functions).
Internally every tag is normalized to a string before matching: `"Pets"` stays `"Pets"`, `{ type: "Pet", id: 5 }` becomes `"Pet:5"`, and `{ type: "Pet" }` becomes `"Pet"`.
## Static vs dynamic tags
A route's `tags` / `invalidatesTags` field can be a **static array** of tags, or a **function** that derives the tags from the request:
- `tags: Tag[]` or `tags: ({ response, params }) => Tag[]`
- `invalidatesTags: Tag[]` or `invalidatesTags: ({ response, variables: { params?, body? } }) => Tag[]`
Dynamism lives at the field level — the whole array is computed by a function — not inside individual tag objects.
### Static tags
```ts
listSettings: {
method: "GET",
path: "/settings",
tags: ["settings"],
} satisfies RouteDefinition, Record, never, Settings>,
updateSettings: {
method: "PUT",
path: "/settings",
invalidatesTags: ["settings"],
} satisfies RouteDefinition, Record, SettingsInput, Settings>,
```
Use static tags for resources whose identity doesn't depend on the request — the current user, feature flags, app-wide settings.
### Dynamic tags
To scope a tag to a tenant, facility, or specific resource, compute it from the params (or response):
```ts
listPosts: {
method: "GET",
path: "/facilities/{facilityId}/posts",
tags: ({ params }) => [{ type: "posts", id: params.facilityId }],
} satisfies RouteDefinition<{ facilityId: string }, { search?: string }, never, Post[]>,
createPost: {
method: "POST",
path: "/facilities/{facilityId}/posts",
invalidatesTags: ({ variables }) => [
{ type: "posts", id: variables.params?.facilityId },
],
} satisfies RouteDefinition<{ facilityId: string }, Record, PostInput, Post>,
```
Now a `createPost` for facility `f1` invalidates `listPosts` for `f1` but _not_ for `f2` — the resolved tags are `"posts:f1"` vs `"posts:f2"`, and only exact matches invalidate.
The `tags` function also receives the `response`, which is handy for detail routes:
```ts
getPost: {
method: "GET",
path: "/posts/{postId}",
tags: ({ response }) => [{ type: "post", id: response?.id }],
} satisfies RouteDefinition<{ postId: string }, Record, never, Post>,
```
### Multiple tags
A read route can register several tags; a write can invalidate several. To make a `deletePost` refresh both the facility's list and the specific item's detail view, tag the read routes and invalidate both:
```ts
getPost: {
method: "GET",
path: "/facilities/{facilityId}/posts/{postId}",
tags: ({ params }) => [
{ type: "post", id: params.postId },
{ type: "posts", id: params.facilityId },
],
} satisfies RouteDefinition<{ facilityId: string; postId: string }, Record, never, Post>,
deletePost: {
method: "DELETE",
path: "/facilities/{facilityId}/posts/{postId}",
invalidatesTags: ({ variables }) => [
{ type: "posts", id: variables.params?.facilityId },
{ type: "post", id: variables.params?.postId },
],
} satisfies RouteDefinition<{ facilityId: string; postId: string }, Record, never, void>,
```
A query is invalidated when **any** of its registered tags matches **any** of the mutation's tags.
## The matching algorithm
Matching is exact-string equality on the normalized form, computed as a set intersection:
1. Each tag — on both sides — is normalized to `"type"` or `"type:id"`.
2. A query key is invalidated when its registered tag set intersects the mutation's tag set.
That means:
| Mutation invalidates | Query registered | Matches? | Why |
| --- | --- | --- | --- |
| `{ type: "post", id: "p1" }` | `{ type: "post", id: "p1" }` | yes | Both normalize to `"post:p1"` |
| `{ type: "post", id: "p1" }` | `{ type: "post", id: "p2" }` | no | `"post:p1"` ≠ `"post:p2"` |
| `{ type: "post" }` | `{ type: "post", id: "p1" }` | no | `"post"` ≠ `"post:p1"` |
| `"posts"` | `{ type: "posts" }` | yes | Both normalize to `"posts"` |
> **Warning:**
> There is **no hierarchical or partial matching**. `{ type: "post" }` does _not_ match `{ type: "post", id: "p1" }` — they normalize to different strings. If you want "invalidate the whole collection _and_ this item", register (and invalidate) both tags explicitly, as in the `deletePost` example above.
## `useM` flow
When you call `mutate(variables)`:
1. **`onMutate`**: [optimistic updates](https://use-q.dev/docs/guides/optimistic-updates.md) are applied. Each target snapshots the previous cache entry.
2. **The mutation runs** through the fetcher.
3. **`onError`** (if it fails): restore every snapshot.
4. **`onSettled`** (always): resolve the route's `invalidatesTags` (static array or function of `{ response, variables }`), append the hook's `additionalInvalidatesTags`, look the combined list up in the `TagRegistry`, then call `queryClient.invalidateQueries` for each matching query key.
Step 4 is the magic. Crucially, invalidation runs in `onSettled` — meaning it runs whether the mutation succeeded or failed. If the mutation failed, the optimistic rollback restored stale data; the invalidation then refetches the truth.
> **Tip:**
> Why `onSettled` and not `onSuccess`? Because failures should refetch too — [Mastering Mutations](https://tkdodo.eu/blog/mastering-mutations-in-react-query). Running invalidation only on success would skip the refetch when the mutation errored, leaving the rolled-back cache marked fresh. At worst, `onSettled` refetches slightly too eagerly — which is correct behavior, never stale behavior.
## `additionalInvalidatesTags`
The schema's `invalidatesTags` should reflect the route's _direct_ effects. For everything else, append a static tag list at the call site:
```tsx
const createPost = useM("createPost", {
additionalInvalidatesTags: ["feed", { type: "notifications", id: currentUserId }],
});
createPost.mutate({ params: { facilityId }, body: { title: "Hello" } });
```
This is the right escape hatch when:
- A page-specific aggregate touches data the schema can't know about.
- You want to refetch a "view" query (e.g. a dashboard summary) without baking the dependency into every mutation's schema definition.
## Avoiding over-invalidation
The most common bug: a too-broad tag. Symptoms — a single mutation refetches the whole app.
Audit checklist:
- Is every `tags` / `invalidatesTags` entry **as specific as it can be**?
- Are there un-scoped tags that should carry an `id`? (e.g. a bare `"posts"` for a list view that's actually per-facility)
- Are there orphan tags — types that no mutation invalidates? They're harmless, but probably indicate a missing `invalidatesTags`.
A useful pattern: share small helper functions so read and write routes stay in sync:
```ts
import type { Schema, Tag } from "@use-q/api-client";
const facilityPosts = (facilityId: string | undefined): Tag => ({
type: "posts",
...(facilityId !== undefined ? { id: facilityId } : {}),
});
export const schema = {
listPosts: {
// …
tags: ({ params }) => [facilityPosts(params.facilityId)],
},
createPost: {
// …
invalidatesTags: ({ variables }) => [facilityPosts(variables.params?.facilityId)],
},
} as const satisfies Schema;
```
## Invalidating manually
`useQClient().invalidateTag` runs the same registry lookup outside a mutation. It accepts a single tag or an array:
```ts
const qc = useQClient();
await qc.invalidateTag({ type: "posts", id: "f1" });
await qc.invalidateTag(["posts", { type: "post", id: "p1" }]);
```
See [`useQClient`](https://use-q.dev/docs/react/use-q-client.md) for the full surface.
## Limitations
- Tags are only consulted at invalidation time. They don't influence query keys or refetch on focus.
- A registry is per-`createApiClient`, so two clients don't see each other's tags (this is usually what you want).
- The registry only knows about queries that are (or were recently) mounted and have received data — a tag can't invalidate a query that never registered.
- Tag matching is exact on the normalized string. There's no glob/wildcard/partial syntax — if you need both "the collection" and "one item" invalidated, register and invalidate both tags explicitly.
---
# Optimistic updates
> Multi-target optimistic patterns — list insert, item update, list delete — with snapshot/rollback semantics.
Optimistic updates let you reflect a mutation's effect in the UI _before_ the server confirms it. `useM`'s `optimisticUpdates` option makes the snapshot + rollback ceremony declarative — the lifecycle from [Concurrent Optimistic Updates](https://tkdodo.eu/blog/concurrent-optimistic-updates-in-react-query): cancel in-flight queries, snapshot, apply, rollback on error, invalidate on settle.
Each entry names a **target** — either an exact route (`{ routeId, input? }`) or a tag list (`{ tags: [...] }`, resolved through the [TagRegistry](https://use-q.dev/docs/guides/tag-invalidation.md) to every currently-registered query) — and an **updater** that receives the previous cache value plus the mutation's `{ params, body }`.
## The lifecycle
For each target query key:
1. **`cancelQueries`** — prevent any in-flight refetch of the target from clobbering your optimistic value.
2. **`getQueryData`** — snapshot the previous value.
3. **`setQueryData`** — apply your `updater` to produce the optimistic value.
4. On **`onError`** — every snapshot is restored automatically.
5. On **`onSettled`** — `useM` runs tag invalidation, which refetches the truth.
You write the `updater`. Everything else is handled.
## Pattern 1: item update
The simplest case: a single mutation updates a single resource.
```tsx
const updatePost = useM("updatePost", {
optimisticUpdates: [
{
target: { routeId: "getPost", input: { params: { facilityId, postId } } },
updater: (prev, vars) => prev && { ...prev, ...vars.body },
},
],
});
updatePost.mutate({
params: { facilityId, postId },
body: { title: "New title" },
});
```
Note `updater` returns `undefined` (well, `prev && …`) when there's nothing in cache — leaving the cache untouched. This keeps `setQueryData` from inserting a half-formed entry.
## Pattern 2: list update
Most apps render lists and details. After an item update, you want both surfaces to reflect the new value without two roundtrips.
```tsx
const updatePost = useM("updatePost", {
optimisticUpdates: [
{
target: { routeId: "getPost", input: { params: { facilityId, postId } } },
updater: (prev, vars) => prev && { ...prev, ...vars.body },
},
{
target: { routeId: "listPosts", input: { params: { facilityId } } },
updater: (prev, vars) =>
prev?.map((p) => (p.id === postId ? { ...p, ...vars.body } : p)),
},
],
});
```
Each target gets its own snapshot, so if the request fails, _both_ caches roll back atomically.
> **Info:**
> A `routeId` target resolves to exactly one query key, built from the `input` you pass — so it must match the `input` the reading `useQ` uses (including `searchParams`). To hit every matching query regardless of `searchParams`, use a tag target instead: `target: { tags: [{ type: "posts", id: facilityId }] }`.
## Pattern 3: list insert
For a create flow, you can prepend a placeholder item and let invalidation later swap the placeholder for the real server payload.
```tsx
const createPost = useM("createPost", {
optimisticUpdates: [
{
target: { routeId: "listPosts", input: { params: { facilityId } } },
updater: (prev, vars) => [
{
// Stable enough for the optimistic phase.
id: `temp-${crypto.randomUUID()}`,
facilityId,
createdAt: new Date().toISOString(),
...vars.body,
},
...(prev ?? []),
],
},
],
});
createPost.mutate({ params: { facilityId }, body: { title: "Hello" } });
```
When the mutation succeeds, `useM` invalidates the list (via the schema's `invalidatesTags`), triggering a refetch — the server's authoritative payload replaces the temporary item.
## Pattern 4: list delete
Removing optimistically is a one-liner:
```tsx
const deletePost = useM("deletePost", {
optimisticUpdates: [
{
target: { routeId: "listPosts", input: { params: { facilityId } } },
updater: (prev) => prev?.filter((p) => p.id !== postId),
},
{
target: { routeId: "getPost", input: { params: { facilityId, postId } } },
updater: () => undefined, // also evict the detail cache
},
],
});
deletePost.mutate({ params: { facilityId, postId } });
```
If the delete fails, both caches restore from snapshot — the user sees the item reappear.
## Pattern 5: paginated lists
For infinite/paginated routes, the cache value is `InfiniteData` — `{ pages: TPage[], pageParams: TPageParam[] }`. Update each page that needs touching:
```tsx
const updatePost = useM("updatePost", {
optimisticUpdates: [
{
target: { routeId: "listPosts", input: { params: { facilityId } } }, // paginated
updater: (prev, vars) => {
if (!prev) return prev;
return {
...prev,
pages: prev.pages.map((page) => ({
...page,
items: page.items.map((p) =>
p.id === postId ? { ...p, ...vars.body } : p,
),
})),
};
},
},
],
});
```
The same shape applies for inserts (prepend to the first page) and deletes (filter every page).
## Cancellation
`useM` calls `queryClient.cancelQueries({ queryKey })` for each target key before applying the optimistic update. That's important: if a `useQ` is mid-fetch when the mutation fires, the in-flight response would otherwise win the race and overwrite your optimistic value.
You don't have to think about this — it's automatic — but it's why the order of operations in `onMutate` matters.
## Why `onSettled` invalidation matters
After the snapshot is restored (or applied), `useM` invalidates tags _no matter what_. Two scenarios:
- **Success path**: the optimistic value was right (or close); invalidation refetches and replaces with truth. No user-visible flicker.
- **Error path**: the snapshot rollback put back stale data; invalidation refetches to make sure the user is looking at the real current state.
This is why optimistic + tag-based caches are a powerful combo: you get fast UI _and_ a self-healing system.
## When to skip optimistic updates
Don't reach for optimistic updates when:
- The user explicitly waits for confirmation (e.g. "Order placed!" with a long-running payment flow).
- The update is hard to render without the server's response (auto-generated IDs that link to other resources, calculated fields).
- The mutation is rare and a brief spinner is fine. Less code = fewer bugs.
A mutation can pair optimistic updates with `useM`'s tag invalidation _selectively_ — you can opt into optimism on the easy cases and let the harder cases just refetch.
## Debugging tips
- **The cache flickers.** Usually your `updater` returns `undefined` for valid inputs. Use `prev && next` rather than mutating-in-place.
- **The cache stays optimistic after error.** The route is missing `invalidatesTags`, so `onSettled` doesn't refetch. Make sure the schema chains `invalidatesTags` ↔ `tags`.
- **The updater never runs.** A tag target only matches queries currently registered in the `TagRegistry` (mounted, with data). A `routeId` target with a mismatched `input` produces a key nothing is cached under — compare against devtools.
- **Two mutations fight.** If two mutations target the same cache, the second snapshot is the first's optimistic value. That's correct behavior, but consider whether the second mutation should `mutateAsync` to wait for the first.
> **Tip:**
> Pair optimistic updates with a small toast that says "Saved" only after the mutation actually succeeds. The user sees the change instantly _and_ knows it was committed. Use `onSuccess` on the per-call options for that.
---
# Bring your own QueryClient
> Share a single QueryClient across multiple createApiClient instances, persistence, and devtools.
By default `createApiClient` constructs its own `QueryClient`. Most apps are fine with that, but you'll want to bring your own when:
- You have **two or more `createApiClient` instances** for separate APIs and want them to share defaults / cache.
- You need to set **`defaultOptions`** that all queries inherit.
- You want to add **persistence** (`@tanstack/query-persist-client-core`) — [Offline React Query](https://tkdodo.eu/blog/offline-react-query).
- You want **devtools** to inspect every query.
- You're integrating into an existing TanStack Query setup.
The `QueryClient` belongs in React context. Query _results_ do not — [React Query and React Context](https://tkdodo.eu/blog/react-query-and-react-context).
## The option
```ts
import { QueryClient } from "@tanstack/react-query";
import { createApiClient } from "@use-q/api-client-react";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: false,
retry: 1,
},
mutations: {
retry: 0,
},
},
});
export const api = createApiClient(schema, {
baseUrl: "https://api.example.com",
queryClient,
});
```
`api.queryClient === queryClient`. Every `useQ` / `useM` runs against this instance, and every other TanStack Query primitive in the app does too.
## Multiple `createApiClient`s
A common case: a tenant-scoped API and a public API in the same app.
```ts
import { QueryClient } from "@tanstack/react-query";
import { createApiClient } from "@use-q/api-client-react";
import { internalSchema } from "./internal/schema";
import { publicSchema } from "./public/schema";
export const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 60_000, retry: 1 },
},
});
export const internal = createApiClient(internalSchema, {
baseUrl: "https://internal.example.com",
headers: () => ({ Authorization: `Bearer ${tokenStore.get()}` }),
queryClient,
});
export const public_ = createApiClient(publicSchema, {
baseUrl: "https://public.example.com",
queryClient,
});
```
Both clients live in one cache. Devtools shows everything. A persister hits all queries at once.
> **Info:**
> Even though the cache is shared, each `createApiClient` owns its **own `TagRegistry`**. So `internal.useM("createPost", ...)` will not invalidate `public_.useQ("listPosts", ...)` — which is what you want: tag namespaces are scoped to a client.
## Mounting the provider
`QueryClientProvider` accepts any `QueryClient`, including the one you constructed:
```tsx
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/api/client";
export function Root({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
```
## Persistence
`@tanstack/query-persist-client-core` lets you serialize the cache to `localStorage`, IndexedDB, or a custom store. Combined with a BYO `QueryClient`, it makes for a great offline-first setup.
```bash
pnpm add @tanstack/query-persist-client-core @tanstack/query-sync-storage-persister
```
```ts
import { QueryClient } from "@tanstack/react-query";
import { persistQueryClient } from "@tanstack/query-persist-client-core";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
const queryClient = new QueryClient({
defaultOptions: {
queries: { gcTime: 24 * 60 * 60_000 }, // 24h
},
});
if (typeof window !== "undefined") {
const persister = createSyncStoragePersister({ storage: window.localStorage });
persistQueryClient({
queryClient,
persister,
maxAge: 24 * 60 * 60_000,
buster: import.meta.env.VITE_APP_VERSION,
});
}
export const api = createApiClient(schema, {
baseUrl: "https://api.example.com",
queryClient,
});
```
The `buster` is critical — bump it whenever the schema/response shape changes so stale serialized entries don't reanimate.
## Devtools
```bash
pnpm add -D @tanstack/react-query-devtools
```
```tsx
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/api/client";
export function Root({ children }: { children: React.ReactNode }) {
return (
{children}
{import.meta.env.DEV && }
);
}
```
The devtools panel groups queries by key — `use-q`'s `["api", method, path, params]` shape makes the tree very easy to navigate.
## Sharing across React + non-React
If you have a plain TypeScript module that needs to read from or write to the cache (e.g. an analytics hook reacting to mutations), use the same `queryClient` directly:
```ts
import { queryClient } from "@/api/client";
queryClient.getQueryCache().subscribe((event) => {
if (event.type === "updated" && event.action.type === "success") {
analytics.track("query_succeeded", { key: event.query.queryKey });
}
});
```
Combine with the `queryKeys` factory to scope subscriptions to specific routes.
## Tips
- **One `QueryClient` per app.** Don't construct it inside a component; that creates a new cache on every render.
- **Put it in a module-level constant** (or in a server-component-aware factory for Next.js).
- **Skip `BYO` for simple apps.** The default `QueryClient` is fine and has zero ceremony.
---
# SSR & loaders
> Use the raw fetcher in React Router, TanStack Router, Next.js server components, and server actions — and hydrate into the client cache.
`use-q`'s framework-agnostic core (`createFetcher`) shines on the server. You can pre-fetch data anywhere — route loaders, RSC, server actions, edge workers — and seamlessly hand it to the client cache.
The ideas are [React Query meets React Router](https://tkdodo.eu/blog/react-query-meets-react-router) and [Seeding the Query Cache](https://tkdodo.eu/blog/seeding-the-query-cache): fetch once on the server, put the result in the same query key the client hook will read, and skip the loading flash. For TanStack Router's first-class Query integration specifically, see TkDodo's [TanStack Router and Query](https://tkdodo.eu/blog/tanstack-router-and-query) — this guide stays on the shared-cache pattern.
## The pattern, in one picture
```
┌──── server ────┐ ┌──── client ────┐
│ createFetcher │ → response → │ setQueryData / │
│ .fetch(…) │ │ HydrationBound│
└────────────────┘ │ /initialData │
└────────────────┘
```
Two things are happening:
1. **Pre-fetch on the server** with a plain fetcher. No React, no `QueryClient`, no schema — `createFetcher` takes a base URL and works with raw paths.
2. **Inject into the client cache** using one of three techniques: `setQueryData`, `HydrationBoundary`, or `initialData`.
The fetcher's low-level API is:
```ts
fetcher.fetch(path, {
method, // defaults to "GET"
params, // fills {placeholders} in the path
searchParams, // appended as ?key=value
body, // JSON-stringified for POST/PUT/PATCH/DELETE
signal,
headers,
});
```
## React Router v6 data loader
```ts
// src/api/server.ts
import { createFetcher } from "@use-q/api-client";
export const serverFetcher = createFetcher({
baseUrl: import.meta.env.VITE_API_BASE_URL,
});
```
```ts
// src/routes/posts.ts
import type { LoaderFunctionArgs } from "react-router-dom";
import { serverFetcher } from "@/api/server";
import type { Post } from "@/api/schema";
export async function postsLoader({ params, request }: LoaderFunctionArgs) {
return serverFetcher.fetch("/facilities/{facilityId}/posts", {
params: { facilityId: params.facilityId! },
signal: request.signal,
});
}
```
```tsx
// src/routes/PostsPage.tsx
import { useLoaderData } from "react-router-dom";
import { useQ } from "@/api/client";
export function PostsPage() {
const loaderPosts = useLoaderData() as Awaited<
ReturnType
>;
const facilityId = "f1"; // ← from route params
const { data } = useQ(
"listPosts",
{ params: { facilityId } },
{ initialData: () => loaderPosts },
);
return data?.map((p) => {p.title});
}
```
`initialData` hydrates the cache without making an extra network round-trip. The component renders synchronously on the first paint.
> **Warning:**
> Make sure the loader-fetched input **matches** the `useQ` input. If the cache key differs (e.g. the loader fetched without `searchParams`, but the hook calls with `{ search: "" }`), TanStack Query sees a different key and fires a new fetch.
## TanStack Router loader
```ts
// routeTree.gen.ts (concept)
import { createFileRoute } from "@tanstack/react-router";
import { serverFetcher } from "@/api/server";
import { api } from "@/api/client";
export const Route = createFileRoute("/posts/$facilityId")({
loader: async ({ params, abortController }) => {
const data = await serverFetcher.fetch(
"/facilities/{facilityId}/posts",
{
params: { facilityId: params.facilityId },
signal: abortController.signal,
},
);
// Hydrate directly into the client cache
api.queryClient.setQueryData(
api.queryKeys.listPosts({
params: { facilityId: params.facilityId },
}),
data,
);
return data;
},
});
```
`setQueryData` is the imperative cousin of `initialData` — same effect, just at a different point in the lifecycle. `api.queryKeys.listPosts(input)` builds exactly the key `useQ("listPosts", input)` reads from, so the hydrated entry is picked up with no extra fetch.
## Next.js server components
In RSC, the recommended pattern is `HydrationBoundary` + `dehydrate`:
```tsx
// app/posts/[facilityId]/page.tsx
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { api } from "@/api/client";
import { PostsClient } from "./PostsClient";
export default async function Page({
params: { facilityId },
}: {
params: { facilityId: string };
}) {
await api.queryClient.prefetchQuery({
queryKey: api.queryKeys.listPosts({ params: { facilityId } }),
queryFn: ({ signal }) =>
api.fetcher.fetch(api.schema.listPosts.path, {
params: { facilityId },
signal,
}),
});
return (
);
}
```
```tsx
// app/posts/[facilityId]/PostsClient.tsx
"use client";
import { useQ } from "@/api/client";
export function PostsClient({ facilityId }: { facilityId: string }) {
const { data } = useQ("listPosts", { params: { facilityId } });
return data?.map((p) => {p.title});
}
```
The client component picks up the dehydrated state from `` and renders synchronously — no flash of loading. The key detail: `api.queryKeys.listPosts(...)` in the prefetch and `useQ("listPosts", ...)` in the client component build the **same** key, so hydration matches.
### Per-request fetcher
Server components must isolate per-request state. Build a fresh fetcher per render so cookies and auth headers don't leak between requests:
```ts
// app/lib/server-fetcher.ts
import { createFetcher } from "@use-q/api-client";
import { cookies } from "next/headers";
export function getServerFetcher() {
return createFetcher({
baseUrl: process.env.API_BASE_URL!,
headers: () => ({
Authorization: `Bearer ${cookies().get("token")?.value ?? ""}`,
}),
});
}
```
Call `getServerFetcher()` at the top of each server component. Don't capture it in a module-level constant — that's the leakage trap.
## Server actions
```ts
"use server";
import { revalidatePath } from "next/cache";
import { getServerFetcher } from "@/app/lib/server-fetcher";
export async function createPostAction(facilityId: string, formData: FormData) {
const post = await getServerFetcher().fetch(
"/facilities/{facilityId}/posts",
{
method: "POST",
params: { facilityId },
body: {
title: String(formData.get("title")),
body: String(formData.get("body")),
},
},
);
revalidatePath(`/posts/${facilityId}`);
return post;
}
```
## Edge workers
`createFetcher` runs on any runtime with `fetch`. In a Cloudflare Worker:
```ts
import { createFetcher } from "@use-q/api-client";
export default {
async fetch(req: Request, env: Env) {
const fetcher = createFetcher({
baseUrl: env.API_BASE_URL,
headers: { "x-internal": env.INTERNAL_KEY },
});
const posts = await fetcher.fetch("/facilities/{facilityId}/posts", {
params: { facilityId: new URL(req.url).searchParams.get("f")! },
});
return Response.json(posts);
},
} satisfies ExportedHandler;
```
(If the runtime requires a bound fetch implementation, pass it explicitly via the `fetch` option: `createFetcher({ baseUrl, fetch: myFetch })`.)
## When to use which hydration technique
| Technique | When | Pros | Cons |
| --- | --- | --- | --- |
| `initialData` (per hook) | One-off pages, React Router / Vite SPAs | Simple; explicit; no dependency on `` | Have to pass params through to the component |
| `setQueryData` (in loader / action) | Imperative wiring, mixed-source loaders | Total control | Easy to mismatch the key |
| `dehydrate` + `HydrationBoundary` | Next.js app router, multiple queries per page | One declaration covers an entire subtree | Requires a `QueryClient` on the server |
## Tips
- **Construct fetchers per request** (or per worker invocation) on the server to avoid leaking auth state between users.
- **Match query keys exactly.** Use `api.queryKeys.(input)` to build keys both server-side and client-side — same function, same shape.
- **Reuse the schema for paths.** The server fetcher is schema-free, but route paths live in your schema module — reference `schema..path` instead of duplicating path strings.
- **Don't ship the React layer to the server bundle.** `@use-q/api-client` has zero React dependency. Keep server-only code in a file that doesn't transitively import `@use-q/api-client-react`.
---
# Monorepo usage
> Place the schema in a shared @org/api-schema package, share with web and CLI consumers, and wire up codegen.
In a monorepo, you usually want **one schema**, shared by every consumer — web app, mobile app, CLI tools, Node services. `use-q` is designed for this: the schema is just a `.ts` module, easy to publish from a workspace package.
## Recommended layout
```
my-org/
├─ apps/
│ ├─ web/ # React app, uses @use-q/api-client-react
│ ├─ cli/ # Node CLI, uses @use-q/api-client
│ └─ worker/ # Cloudflare Worker, uses @use-q/api-client
├─ packages/
│ ├─ api-schema/ # The single source of truth
│ ├─ api-spec/ # (optional) OpenAPI 3.x source for codegen
│ └─ ui/ # Shared React components
├─ pnpm-workspace.yaml
└─ package.json
```
`packages/api-schema/` exports `schema` (and any handcrafted types). Everyone else imports it.
## The `api-schema` package
```json
// packages/api-schema/package.json
{
"name": "@my-org/api-schema",
"version": "0.1.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"codegen": "use-q-codegen --input ../api-spec/openapi.yaml --output ./src/generated.ts",
"build": "tsup src/index.ts --format esm,cjs --dts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@use-q/api-client": "workspace:*"
}
}
```
```ts
// packages/api-schema/src/index.ts
import type { Schema, Tag } from "@use-q/api-client";
import { schema as generated } from "./generated";
const facilityPosts = (facilityId: string | undefined): Tag => ({
type: "posts",
...(facilityId !== undefined ? { id: facilityId } : {}),
});
// Layer hand-edited tags on top of the codegen output
export const schema = {
...generated,
listPosts: {
...generated.listPosts,
tags: ({ params }) => [facilityPosts(params.facilityId)],
},
createPost: {
...generated.createPost,
invalidatesTags: ({ variables }) => [
facilityPosts(variables.params?.facilityId),
],
},
} as const satisfies Schema;
// Re-export the generated response/body types (Post, PostInput, …)
export * from "./generated";
export type { Schema } from "@use-q/api-client";
```
The whole point of this file: codegen handles the boring 90% (types, paths, params), you sprinkle tags and invalidation hand-typed. Rerunning codegen never touches your tag wiring.
## Consuming from the web app
```ts
// apps/web/src/api/client.ts
import { createApiClient } from "@use-q/api-client-react";
import { schema } from "@my-org/api-schema";
export const api = createApiClient(schema, {
baseUrl: import.meta.env.VITE_API_BASE_URL,
headers: () => ({
Authorization: `Bearer ${tokenStore.get() ?? ""}`,
}),
});
export const { useQ, useM, useInfiniteQ, useSuspenseQ, useQClient } = api;
```
```json
// apps/web/package.json (excerpt)
{
"dependencies": {
"@my-org/api-schema": "workspace:*",
"@use-q/api-client-react": "workspace:*",
"@tanstack/react-query": "^5.0.0",
"react": "^18.0.0"
}
}
```
## Consuming from a Node CLI
```ts
// apps/cli/src/index.ts
import { createFetcher } from "@use-q/api-client";
import { schema, type Post } from "@my-org/api-schema";
const fetcher = createFetcher({
baseUrl: process.env.API_BASE_URL!,
headers: () => ({
Authorization: `Bearer ${process.env.API_TOKEN!}`,
}),
});
const posts = await fetcher.fetch(schema.listPosts.path, {
params: { facilityId: process.argv[2]! },
});
console.table(posts.map(({ id, title }) => ({ id, title })));
```
No `QueryClient`, no React. `createFetcher` itself is schema-free — it takes a base URL and raw paths — but the shared schema still earns its keep as the source of route paths and response types (tags just don't do anything in this context).
## Consuming from a Cloudflare Worker
```ts
// apps/worker/src/index.ts
import { createFetcher } from "@use-q/api-client";
import { schema, type Post } from "@my-org/api-schema";
export default {
async fetch(req: Request, env: Env) {
const fetcher = createFetcher({
baseUrl: env.API_BASE_URL,
});
const posts = await fetcher.fetch(schema.listPosts.path, {
params: { facilityId: env.DEFAULT_FACILITY_ID },
});
return Response.json(posts);
},
} satisfies ExportedHandler;
```
## Codegen workflow
### Put the OpenAPI spec in `packages/api-spec`
```yaml
# packages/api-spec/openapi.yaml
openapi: 3.0.3
info:
title: My API
version: 0.1.0
paths:
# ...
```
### Run codegen as a `prebuild` script in `api-schema`
```json
{
"scripts": {
"codegen": "use-q-codegen --input ../api-spec/openapi.yaml --output ./src/generated.ts",
"prebuild": "pnpm codegen"
}
}
```
### Wire it into CI so the generated file is always fresh
```yaml
- run: pnpm --filter @my-org/api-schema codegen
- run: |
if ! git diff --exit-code; then
echo "::error::Generated schema is stale. Run pnpm --filter api-schema codegen."
exit 1
fi
```
### Layer tags in `src/index.ts`
Never edit `src/generated.ts` by hand — hand-written tag wiring lives only in `src/index.ts`, so rerunning codegen can't clobber it.
See [Codegen](https://use-q.dev/docs/core/codegen.md) for CLI flags.
## TypeScript project references
With strict TS, you'll want a `tsconfig.json` per package and a root `tsconfig.json` with `references`:
```json
// tsconfig.json (root)
{
"files": [],
"references": [
{ "path": "packages/api-schema" },
{ "path": "apps/web" },
{ "path": "apps/cli" },
{ "path": "apps/worker" }
]
}
```
```json
// packages/api-schema/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"composite": true
},
"include": ["src"]
}
```
```json
// apps/web/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"references": [{ "path": "../../packages/api-schema" }],
"compilerOptions": {
"rootDir": "./src",
"noEmit": true
},
"include": ["src"]
}
```
This gives you incremental builds and accurate type-checking across the graph.
## Tips
- **One schema, no duplication.** Resist the urge to copy slices of `schema` into individual apps. The whole point of a monorepo is that the source of truth is shared.
- **`workspace:*` everywhere.** Both for `@my-org/api-schema` and for any `@use-q/*` packages you author.
- **CI: run codegen, then diff.** This catches OpenAPI drift before merging.
- **Don't ship `@use-q/api-client-react` to non-React apps.** The schema package depends only on `@use-q/api-client` (the framework-agnostic core).
> **Tip:**
> If your monorepo has more than one upstream API, give each its own schema package — `@my-org/internal-schema`, `@my-org/billing-schema`, etc. Then build a `createApiClient` per API in the consumer, sharing one `QueryClient`. See [BYO QueryClient](https://use-q.dev/docs/guides/byo-query-client.md).
---
# RouteDefinition
> Complete type reference for RouteDefinition — every field, generics, Tag, and PaginationDef variants.
A `RouteDefinition` describes one route in your schema. A whole `Schema` is `Record` — keyed by an arbitrary route id (typically the `operationId` or `"METHOD path"`).
## Type
```ts
interface RouteDefinition<
TParams = unknown,
TSearch = unknown,
TBody = unknown,
TResponse = unknown,
> {
method: HttpMethod;
path: string;
tags?:
| ReadonlyArray
| ((ctx: { response: TResponse; params: TParams }) => ReadonlyArray);
invalidatesTags?:
| ReadonlyArray
| ((ctx: {
response: TResponse | undefined;
variables: { params?: TParams; body?: TBody };
}) => ReadonlyArray);
pagination?: PaginationDef;
}
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
```
Only `method`, `path`, `tags`, `invalidatesTags`, and `pagination` exist at runtime. The four generics — path params, search params, body, response — are attached at the type level with a `satisfies` clause:
```ts
import type { RouteDefinition } from "@use-q/api-client";
const getPost = {
method: "GET",
path: "/facilities/{facilityId}/posts/{postId}",
} satisfies RouteDefinition<
{ facilityId: string; postId: string }, // TParams
Record, // TSearch
never, // TBody
Post // TResponse
>;
```
> **Info:**
> `RouteDefinition` also declares optional phantom fields (`__params`, `__search`, `__body`, `__response`) that exist purely so TypeScript can infer the generics back out of a schema. They're never read at runtime — never assign them.
## Fields
### `method`
The HTTP method. One of `"GET" | "POST" | "PUT" | "PATCH" | "DELETE"`.
| Method | Cached as a query? | Typical hook |
| --- | --- | --- |
| `GET` | yes | `useQ` / `useSuspenseQ` / `useInfiniteQ` |
| `POST` | no | `useM` |
| `PUT` | no | `useM` |
| `PATCH` | no | `useM` |
| `DELETE` | no | `useM` |
For non-`GET` methods with a body, the fetcher JSON-stringifies the body and sets `Content-Type: application/json` (unless you provide your own).
### `path`
A string with `{paramName}` placeholders, e.g. `/facilities/{facilityId}/posts/{postId}`. Each placeholder is filled from the call-site `params`, URI-encoded. A placeholder with no corresponding value throws at request time, so keep the placeholders in sync with `TParams`:
```ts
{
method: "GET",
path: "/facilities/{facilityId}/posts/{postId}",
} satisfies RouteDefinition<{ facilityId: string; postId: string }, Record, never, Post>
```
### Path params (`TParams`)
The first generic. All placeholder values are **required** at request time — a missing or `undefined`/`null` value throws. Use `Record` for routes without path params.
### Search params (`TSearch`)
The second generic. Optional properties become optional at the call site:
```ts
satisfies RouteDefinition<…, { search?: string; page?: number }, …, …>
```
`undefined`/`null` values are stripped before request and query-key construction, so they don't fragment the cache. Array values are appended as repeated keys (`?id=1&id=2`).
### Body (`TBody`)
The third generic, for `POST`/`PUT`/`PATCH`/`DELETE`. Use `never` for routes that take no body. The value is `JSON.stringify`-ed with an automatic `Content-Type: application/json` header:
```ts
satisfies RouteDefinition<…, …, { title: string; body: string }, …>
```
For non-JSON bodies (form data, blobs), serialize yourself and pass a `headers` override per-call.
### Response (`TResponse`)
The fourth generic — the successful-response type. Empty response bodies (e.g. 204 No Content) resolve to `undefined`, so type those as `undefined` (or `void`):
```ts
satisfies RouteDefinition<…, …, …, Post>; // 200 with JSON
satisfies RouteDefinition<…, …, …, Post[]>; // list response
satisfies RouteDefinition<…, …, …, undefined>; // 204
satisfies RouteDefinition<…, …, …, { items: Post[]; total: number }>; // paginated
```
### `tags`
Labels the cache entries produced by this route (only meaningful for `GET` routes). Either a static `ReadonlyArray`, or a function that derives tags from the response and params:
```ts
// Static
tags: ["Posts"];
// Dynamic — the whole field is a function
tags: ({ response, params }) => [{ type: "post", id: response.id }];
```
See [Tag invalidation](https://use-q.dev/docs/guides/tag-invalidation.md) for the full lifecycle.
### `invalidatesTags`
For mutating routes: which tags to invalidate after the mutation settles. Either a static `ReadonlyArray`, or a function receiving the mutation's response and variables:
```ts
// Static
invalidatesTags: ["Posts"];
// Dynamic
invalidatesTags: ({ response, variables }) => [
{ type: "post", id: variables.params?.postId },
"Posts",
];
```
The function's `response` may be `undefined` (the mutation may have failed), and `variables` is `{ params?, body? }` from the `mutate` call.
### `pagination`
A `PaginationDef` (see below) for paginated routes. Required for `useInfiniteQ` — the hook throws if the route has no `pagination` block:
```ts
pagination: { kind: "page-number", pageParam: "page" };
```
## `Tag`
```ts
type Tag = string | { type: string; id?: string | number };
```
A tag is either a plain string or a `{ type, id? }` object — nothing else. There are no function-valued fields inside a tag; when you need tags computed from data, make the whole `tags`/`invalidatesTags` field a function (as shown above).
Tags normalize to strings for matching: `{ type: "post", id: 42 }` → `"post:42"`, `{ type: "post" }` → `"post"`, and string tags stay as-is (`tagToString` is exported if you need the same normalization).
```ts
// Plain string
"Posts"
// Type only
{ type: "settings" }
// Type + id
{ type: "post", id: postId }
```
See [Tag invalidation > Static vs dynamic tags](https://use-q.dev/docs/guides/tag-invalidation.md) for matching semantics.
## `PaginationDef`
Two variants — page-number and cursor:
```ts
type PaginationDef =
| {
kind: "page-number";
pageParam: string; // searchParams key for the page number, e.g. "page"
itemsKey?: string; // response field containing items (default "items")
totalKey?: string; // response field containing total count (default "total")
}
| {
kind: "cursor";
pageParam: string; // searchParams key for the cursor, e.g. "cursor"
cursorKey?: string; // response field containing the next cursor (default "nextCursor")
itemsKey?: string; // response field containing items (default "items")
};
```
### Page-number example
```ts
listPosts: {
method: "GET",
path: "/facilities/{facilityId}/posts",
pagination: { kind: "page-number", pageParam: "page" } as const,
} satisfies RouteDefinition<
{ facilityId: string },
{ page?: number; limit?: number },
never,
{ items: Post[]; total: number }
>,
```
### Cursor example
```ts
listFeed: {
method: "GET",
path: "/feed",
pagination: { kind: "cursor", pageParam: "cursor" } as const,
} satisfies RouteDefinition<
Record,
{ cursor?: string; limit?: number },
never,
{ items: Post[]; nextCursor: string | null }
>,
```
## Inferred helpers
`@use-q/api-client` exports utility types that extract the generics back out of a route definition:
```ts
import type {
Schema,
AnyRouteDefinition,
RouteInput,
RouteParams,
RouteSearch,
RouteBody,
RouteResponse,
} from "@use-q/api-client";
type ListPostsRoute = typeof schema["listPosts"];
type ListPostsParams = RouteParams; // { facilityId: string }
type ListPostsSearch = RouteSearch; // { page?: number; limit?: number }
type ListPostsResponse = RouteResponse; // { items: Post[]; total: number }
// The shape hooks and queryKeys accept for a route:
type ListPostsInput = RouteInput; // { params?; searchParams?; body? }
```
These are how `useQ`, `useM`, and friends produce their fully-typed call-site shapes.
## Putting it together
```ts
import type { RouteDefinition } from "@use-q/api-client";
interface Post {
id: string;
facilityId: string;
title: string;
body: string;
createdAt: string;
}
export const schema = {
listPosts: {
method: "GET",
path: "/facilities/{facilityId}/posts",
tags: ["Posts"],
pagination: { kind: "page-number", pageParam: "page" } as const,
} satisfies RouteDefinition<
{ facilityId: string },
{ page?: number; limit?: number },
never,
{ items: Post[]; total: number }
>,
getPost: {
method: "GET",
path: "/facilities/{facilityId}/posts/{postId}",
tags: ({ response }) => [{ type: "post", id: response.id }],
} satisfies RouteDefinition<
{ facilityId: string; postId: string },
Record,
never,
Post
>,
createPost: {
method: "POST",
path: "/facilities/{facilityId}/posts",
invalidatesTags: ["Posts"],
} satisfies RouteDefinition<
{ facilityId: string },
Record,
{ title: string; body: string },
Post
>,
} as const;
export type AppSchema = typeof schema;
```
---
# CreateFetcherOptions
> Every option accepted by createFetcher — type, default, and example.
```ts
function createFetcher(options: CreateFetcherOptions): FetcherInstance;
interface CreateFetcherOptions {
baseUrl: string;
headers?: HeadersInit | (() => HeadersInit | Promise);
fetch?: typeof fetch;
parseError?: (input: { response: Response; data: unknown }) => unknown;
onError?: (error: unknown) => void;
}
```
## Fields
### `baseUrl`
| Type | Default | Required |
| --- | --- | --- |
| `string` | _none_ | yes |
Prepended to every request path. Trailing slashes are normalized, so both of these work:
```ts
createFetcher({ baseUrl: "https://api.example.com" });
createFetcher({ baseUrl: "https://api.example.com/" });
```
You can include a path prefix:
```ts
createFetcher({ baseUrl: "https://api.example.com/v1" });
// Path "/posts" → "https://api.example.com/v1/posts"
```
Paths that are already absolute `http(s)://` URLs bypass `baseUrl` joining entirely.
### `fetch`
| Type | Default |
| --- | --- |
| `typeof fetch` | `globalThis.fetch` |
A custom `fetch` implementation. Common use cases:
```ts
// Alternative fetch implementations (undici, polyfills, test doubles)
import { fetch as undiciFetch } from "undici";
createFetcher({ baseUrl: "…", fetch: undiciFetch });
// Wrapping with retry middleware
const wrappedFetch: typeof fetch = async (input, init) => {
for (let i = 0; i < 3; i++) {
const res = await fetch(input, init);
if (res.status < 500) return res;
}
return fetch(input, init);
};
createFetcher({ baseUrl: "…", fetch: wrappedFetch });
```
### `headers`
| Type | Default |
| --- | --- |
| `HeadersInit` \| `() => HeadersInit \| Promise` | `undefined` |
Headers applied to every request. Any `HeadersInit` works — a plain object, a `Headers` instance, or an array of `[key, value]` entries. Two shapes:
#### Static
```ts
createFetcher({
baseUrl: "…",
headers: {
"x-api-version": "2026-01-01",
"x-client": "web",
},
});
```
#### Function (sync or async)
```ts
createFetcher({
baseUrl: "…",
headers: async () => ({
Authorization: `Bearer ${await getAccessToken()}`,
"x-tenant-id": currentTenantId(),
}),
});
```
Called on every `fetcher.fetch()` invocation and awaited if it returns a Promise. Useful for token refresh.
Per-call `headers` always win over the constructor-level headers:
```ts
fetcher.fetch("/facilities/{facilityId}/posts/{postId}", {
params: { facilityId, postId },
headers: { "x-debug": "1" }, // merged with / overrides defaults
});
```
### `parseError`
| Type | Default |
| --- | --- |
| `(input: { response: Response; data: unknown }) => unknown` | `undefined` (raw body used as `data`) |
Runs for any non-2xx response. Receives a single object with the raw `Response` and the already-parsed body (`data` is JSON when the response's content-type is `application/json`, the raw text otherwise, or `undefined` for an empty body). The return value becomes `ApiError.data`:
```ts
interface ApiProblem {
type: string;
title: string;
detail: string;
}
createFetcher({
baseUrl: "…",
parseError: ({ response, data }) => {
const body = data as Partial | null;
return {
type: body?.type ?? "about:blank",
title: body?.title ?? response.statusText,
detail: body?.detail ?? "",
} satisfies ApiProblem;
},
});
```
Two special cases:
- Returning an `ApiError` instance throws that instance as-is (full control over message, subclassing, etc.).
- Returning `undefined`/`null` falls back to the raw parsed body as `data`.
Without `parseError`, `ApiError.data` is the raw parsed body (typed as `unknown`). Narrow it at catch sites with `isApiError(err)`.
### `onError`
| Type | Default |
| --- | --- |
| `(error: unknown) => void` | `undefined` |
A side-effecting hook fired for every failure (HTTP error _or_ network error) just before the error is thrown. Use it for telemetry and cross-cutting auth:
```ts
import { isApiError } from "@use-q/api-client";
import * as Sentry from "@sentry/browser";
createFetcher({
baseUrl: "…",
onError: (err) => {
if (isApiError(err) && err.status === 401) {
tokenStore.clear();
window.location.assign("/login");
return;
}
Sentry.captureException(err);
},
});
```
For HTTP failures, `err` is the normalized `ApiError` — `status`, `statusText`, `url`, `method`, and `data` are all on the error itself, so no separate context object is needed. For network failures (fetch itself rejecting — DNS, CORS, abort…), `err` is the raw thrown error and `isApiError(err)` is `false`.
`onError`'s return value is ignored; the original error is always thrown afterwards.
## Returned shape
```ts
interface FetcherInstance {
baseUrl: string;
fetch(
path: string,
options?: FetcherFetchOptions,
): Promise;
}
interface FetcherFetchOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; // default "GET"
params?: Record;
searchParams?: SearchParams;
body?: unknown;
signal?: AbortSignal;
headers?: HeadersInit;
}
```
`fetch` is path-based: `{param}` placeholders in `path` are filled from `params` (URI-encoded, throwing on missing values), `searchParams` are appended to the URL, and the response type is whatever you pass as the `TResponse` generic. Schema-typed, route-id-based calls come from [`createApiClient`](https://use-q.dev/docs/api-reference/create-api-client-options.md), which wraps this same fetcher.
See [`createFetcher`](https://use-q.dev/docs/core/create-fetcher.md) for usage recipes.
---
# CreateApiClientOptions
> Every option accepted by createApiClient — extends CreateFetcherOptions with queryClient.
```ts
function createApiClient(
schema: TSchema,
options: CreateApiClientOptions,
): ApiClient;
interface CreateApiClientOptions extends CreateFetcherOptions {
queryClient?: QueryClient;
}
```
`CreateApiClientOptions` is `CreateFetcherOptions` plus one extra field. Everything from [Fetcher options](https://use-q.dev/docs/api-reference/create-fetcher-options.md) applies; the React-specific knob is `queryClient`.
Note that the schema is a **runtime** argument, not just a type parameter — the hooks read `tags`, `invalidatesTags`, and `pagination` off the route definitions at runtime to drive invalidation and infinite queries.
## Fetcher-inherited fields
These behave exactly like in `createFetcher`. Refer to that page for the full discussion:
| Field | Type | Required |
| --- | --- | --- |
| `baseUrl` | `string` | yes |
| `headers` | `HeadersInit` or sync/async function returning one | no |
| `fetch` | `typeof fetch` | no |
| `parseError` | `({ response, data }) => unknown` | no |
| `onError` | `(error) => void` | no |
## React-only fields
### `queryClient`
| Type | Default |
| --- | --- |
| `QueryClient` (from `@tanstack/react-query`) | a freshly constructed `QueryClient` |
If you provide a `QueryClient`, `createApiClient` uses it as-is. If not, it constructs one with TanStack Query's defaults.
Use cases:
- **Share defaults across multiple `createApiClient` instances.**
- **Configure `defaultOptions`** (staleTime, gcTime, retry).
- **Hook into persistence**, devtools, or other TanStack Query primitives.
- **Embed `use-q` into an existing TanStack Query setup.**
```ts
import { QueryClient } from "@tanstack/react-query";
import { createApiClient } from "@use-q/api-client-react";
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false },
mutations: { retry: 0 },
},
});
export const api = createApiClient(schema, {
baseUrl: "https://api.example.com",
queryClient,
});
```
See [BYO QueryClient](https://use-q.dev/docs/guides/byo-query-client.md) for full patterns (persistence, devtools, multiple clients).
## Returned shape
```ts
const api = createApiClient(schema, options);
api.useQ; // (routeId, input?, options?) — read hook
api.useM; // (routeId, options?) — write hook
api.useInfiniteQ; // (routeId, input?, options?) — paginated read hook
api.useSuspenseQ; // (routeId, input?, options?) — Suspense read hook
api.useQClient; // () — typed cache-control hook
api.fetcher; // FetcherInstance
api.queryClient; // QueryClient
api.queryKeys; // QueryKeysFactory
api.isApiError; // type guard, re-exported for convenience
api.schema; // the schema you passed in
api._tagRegistry; // internal TagRegistry (semi-private)
```
| Field | Purpose |
| --- | --- |
| `useQ` | Read hook (TanStack Query `useQuery` semantics). Called as `useQ(routeId, input?, options?)` where `input = { params?, searchParams? }`. |
| `useM` | Write hook with optimistic updates + tag invalidation. Called as `useM(routeId, options?)`; variables `{ params?, body?, searchParams? }` go to `mutate`. |
| `useInfiniteQ` | Paginated reads. Requires the route to have a `pagination` block — throws otherwise. |
| `useSuspenseQ` | Suspense-friendly read, same signature as `useQ`. |
| `useQClient` | Returns `{ invalidateTag, invalidate, invalidateAll, setData, updateData, prefetch }`. |
| `fetcher` | The framework-agnostic fetcher backing all hooks. Use in loaders/RSC. |
| `queryClient` | The `QueryClient` (yours, or constructed). Pass to ``. |
| `queryKeys` | Per-route key factory: `api.queryKeys.routeId(input?)` returns the canonical `["api", METHOD, resolvedPath, sortedSearchParams]` key, where `input = { params?, searchParams? }`. |
| `isApiError` | Type guard, re-exported for convenience. |
| `schema` | The runtime schema, exposed for introspection. |
| `_tagRegistry` | Internal `TagRegistry`. Exposed for testing and advanced patterns — treat as semi-private. |
## Type parameters
```ts
createApiClient(schema, options);
```
**`TSchema`** is inferred from the `schema` argument, so you rarely write it explicitly — every hook, `queryKeys` entry, and `useQClient` method is typed from it.
Error typing is handled at catch sites rather than as a client-level generic: shape the payload with `parseError` and narrow with `isApiError`:
```ts
interface ApiProblem {
type: string;
title: string;
detail: string;
}
export const api = createApiClient(schema, {
baseUrl: "https://api.example.com",
parseError: ({ response, data }) => {
const json = data as Partial | null;
return {
type: json?.type ?? "about:blank",
title: json?.title ?? response.statusText,
detail: json?.detail ?? "",
} satisfies ApiProblem;
},
});
// Anywhere an error surfaces:
if (api.isApiError(error)) {
console.error(error.data.title);
}
```
## See also
- [`createApiClient` walkthrough](https://use-q.dev/docs/react/create-api-client.md) — the recommended pattern (`src/api/client.ts` + hook re-exports).
- [`CreateFetcherOptions`](https://use-q.dev/docs/api-reference/create-fetcher-options.md) — every fetcher-side field.
- [BYO QueryClient](https://use-q.dev/docs/guides/byo-query-client.md) — sharing one `QueryClient` across multiple clients, persistence, devtools.