Skip to content

React Query hooks

@codewithagents/openapi-react-query reads your OpenAPI 3.x spec and writes a fully typed hooks.ts file alongside the client that @codewithagents/openapi-gen produces. Every GET endpoint becomes a useQuery hook, every write operation becomes a useMutation hook. The package runs on top of React Query v5 and derives all types directly from the generated client: no duplicate type declarations, no maintenance.

Running the generator produces two files in your output directory:

FileContents
hooks.tsOne typed useQuery hook per GET, one useMutation per POST/PUT/PATCH/DELETE, and a structured key factory per resource
test-utils.tscreateTestQueryClient() and createWrapper() helpers: drop-in boilerplate eliminators for your test suites

Key guarantees:

  • One hook per operation. Every GET maps to a useQuery variant; every write maps to a useMutation. Types are derived directly from the generated client: Awaited<ReturnType<typeof fn>> for data, Parameters<typeof fn>[N] for variables.
  • Smart detail hooks. Path-parameter hooks widen the parameter type to string | undefined | null and set enabled: false automatically when the value is nullish. No enabled: !!id at every call site.
  • Key factories included. Every resource gets a structured resourceKeys object: all(), list(params), detail(id). Use these directly with queryClient.invalidateQueries for consistent cache management.
  • Auto-invalidate on mutation. Set auto_invalidate: true and mutation hooks call queryClient.invalidateQueries on success with no boilerplate at the call site.
  • Suspense variants. Set suspense: true to generate a useSuspense* hook alongside every query hook. Data is never undefined inside a Suspense boundary.
  • Prettier-clean output. Every generated file passes prettier --check with your project’s own config.
Terminal window
npm i -D @codewithagents/openapi-react-query

You also need @codewithagents/openapi-gen (the client generator this package builds on) and @tanstack/react-query as a runtime peer dependency:

Terminal window
npm i -D @codewithagents/openapi-gen
Terminal window
npm i @tanstack/react-query

1. Create openapi-react-query.config.json in your project root:

{
"input_openapi": "./openapi.json",
"output": "./src/api"
}

2. Run both generators (openapi-gen first):

Terminal window
npx openapi-gen
npx openapi-react-query

Or wire them together in package.json:

{
"scripts": {
"generate": "openapi-gen && openapi-react-query"
}
}

3. Import from the generated hooks file:

import { useListTasks, useCreateTask } from './src/api/hooks'
function TaskList() {
const { data, isLoading } = useListTasks({ status: 'open' })
const create = useCreateTask()
if (isLoading) return <Spinner />
return (
<>
{data?.items.map((task) => (
<Task key={task.id} task={task} />
))}
<button onClick={() => create.mutate({ title: 'New task' })}>Add</button>
</>
)
}

The config file must be valid JSON. By default the generator looks for openapi-react-query.config.json in the current working directory. Use --config <path> to point at a different file:

Terminal window
npx openapi-react-query --config ./config/openapi-react-query.config.json
FieldTypeRequiredDefaultDescription
input_openapistringYesn/aPath to your OpenAPI spec (JSON or YAML)
outputstringYesn/aDirectory to write generated files (should match your openapi-gen output)
stale_timenumberNo0staleTime in ms applied to all useQuery hooks
gc_timenumberNo300000gcTime in ms applied to all useQuery hooks
suspensebooleanNofalseWhen true, generates a useSuspense* variant alongside every query hook
auto_invalidatebooleanNofalseWhen true, mutation hooks auto-invalidate related resource queries on success
overridesobjectNononePer-resource cache timing overrides (see Per-resource cache timing)

Full config example:

{
"input_openapi": "./openapi.json",
"output": "./src/api",
"stale_time": 30000,
"gc_time": 300000,
"suspense": true,
"auto_invalidate": true,
"overrides": {
"platforms": { "stale_time": 86400000 },
"settings": { "stale_time": 5000, "gc_time": 60000 }
}
}

The main output file. For a spec with a /tasks resource the generated file contains three sections.

Key factory: one per resource, used for consistent cache invalidation:

// src/api/hooks.ts (auto-generated)
export const taskKeys = {
all: () => ['tasks'] as const,
list: (params?: Parameters<typeof listTasks>[0]) => ['tasks', 'list', params] as const,
detail: (id: string) => ['tasks', id] as const,
}

Query hooks: one per GET operation:

// List hook: params are optional, passed directly to the query key and fetch function
export function useListTasks(
params?: Parameters<typeof listTasks>[0],
options?: Omit<
UseQueryOptions<Awaited<ReturnType<typeof listTasks>>, ApiError>,
'queryKey' | 'queryFn'
>
) {
return useQuery<Awaited<ReturnType<typeof listTasks>>, ApiError>({
queryKey: taskKeys.list(params),
queryFn: () => listTasks(params),
staleTime: 30000,
gcTime: 300000,
...options,
})
}
// Detail hook: id widened to allow undefined/null, auto-disabled until id is set
export function useGetTask(
id: string | undefined | null,
options?: Omit<
UseQueryOptions<Awaited<ReturnType<typeof getTask>>, ApiError>,
'queryKey' | 'queryFn'
>
) {
return useQuery<Awaited<ReturnType<typeof getTask>>, ApiError>({
queryKey: taskKeys.detail(id!),
queryFn: () => getTask(id!),
staleTime: 30000,
gcTime: 300000,
enabled: id != null && (options?.enabled ?? true),
...options,
})
}

Mutation hooks: one per POST/PUT/PATCH/DELETE:

export function useCreateTask(
options?: Omit<
UseMutationOptions<
Awaited<ReturnType<typeof createTask>>,
ApiError,
Parameters<typeof createTask>[0]
>,
'mutationFn'
>
) {
return useMutation<
Awaited<ReturnType<typeof createTask>>,
ApiError,
Parameters<typeof createTask>[0]
>({
mutationFn: (vars) => createTask(vars),
...options,
})
}
// Update/patch: variables are destructured into { id, body } to match the client signature
export function useUpdateTask(
options?: Omit<
UseMutationOptions<
Awaited<ReturnType<typeof updateTask>>,
ApiError,
{ id: string; body: Parameters<typeof updateTask>[1] }
>,
'mutationFn'
>
) {
return useMutation<
Awaited<ReturnType<typeof updateTask>>,
ApiError,
{ id: string; body: Parameters<typeof updateTask>[1] }
>({
mutationFn: ({ id, body }) => updateTask(id, body),
...options,
})
}
// Delete with a single path param: variables type is 'string' directly
export function useDeleteTask(
options?: Omit<
UseMutationOptions<Awaited<ReturnType<typeof deleteTask>>, ApiError, string>,
'mutationFn'
>
) {
return useMutation<Awaited<ReturnType<typeof deleteTask>>, ApiError, string>({
mutationFn: (id) => deleteTask(id),
...options,
})
}

A testing helper file generated alongside hooks.ts. It exports createTestQueryClient() and createWrapper() with no new dependencies: everything it imports (@tanstack/react-query, react) is already a peer dependency you have installed.

See Testing your hooks for full copy-pasteable examples.

import { useListTasks } from './src/api/hooks'
function TaskBoard() {
// params are optional; omitting them fetches all tasks
const { data, isLoading, isError, error } = useListTasks({ status: 'pending', page: 1 })
if (isLoading) return <Spinner />
if (isError) return <p>Error: {error.message}</p>
return (
<ul>
{data?.items.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
)
}

The path-parameter hook (useGetTask) widens id to string | undefined | null and sets enabled: false automatically when the value is nullish. No enabled: !!id required at the call site:

import { useGetTask } from './src/api/hooks'
function TaskDetail({ taskId }: { taskId: string | null }) {
// Hook does not fire while taskId is null; no boilerplate needed
const { data, isLoading } = useGetTask(taskId)
if (isLoading) return <Spinner />
return <h2>{data?.title}</h2>
}
import { useCreateTask } from './src/api/hooks'
function CreateTaskForm() {
const create = useCreateTask({
onSuccess: (task) => {
console.log('Created task', task.id)
},
onError: (err) => {
console.error(err.status, err.body)
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
create.mutate({ title: 'New task' })
}}
>
<button type="submit" disabled={create.isPending}>
Add
</button>
</form>
)
}

When suspense: true is set in config, a useSuspense* hook is generated alongside every query hook. The data type is never undefined inside a Suspense boundary:

import { Suspense } from 'react'
import { useSuspenseGetTask } from './src/api/hooks'
function TaskDetail({ taskId }: { taskId: string }) {
// data is guaranteed non-undefined: parent must wrap in <Suspense>
const { data } = useSuspenseGetTask(taskId)
return <h2>{data.title}</h2>
}
function TaskPage({ taskId }: { taskId: string }) {
return (
<Suspense fallback={<Spinner />}>
<TaskDetail taskId={taskId} />
</Suspense>
)
}

Set auto_invalidate: true in config and mutation hooks automatically call queryClient.invalidateQueries on success. No useQueryClient boilerplate at the call site:

// With auto_invalidate: true in config
const create = useCreateTask()
create.mutate({ title: 'New task' })
// taskKeys.all() is invalidated automatically on success
const update = useUpdateTask()
update.mutate({ id: 'task-1', body: { title: 'Updated' } })
// taskKeys.all() AND taskKeys.detail('task-1') are both invalidated on success

Invalidation scope:

HTTP methodQueries invalidated
POSTresourceKeys.all()
PUT / PATCHresourceKeys.all() + resourceKeys.detail(id)
DELETEresourceKeys.all()

Your own onSuccess callback (if provided in options) is called after auto-invalidation runs. The generated onSuccess composes your callback rather than replacing it. The emitted object spreads ...options first, then declares the onSuccess key immediately after, so the generated onSuccess intentionally overwrites any caller-provided onSuccess while still invoking it internally via options?.onSuccess?.(...args):

// The generated mutation hook (simplified, auto_invalidate: true)
// ...options is spread first; onSuccess follows it, overriding any onSuccess in options
onSuccess: (...args) => {
queryClient.invalidateQueries({ queryKey: taskKeys.all() })
options?.onSuccess?.(...args) // your callback runs after invalidation
}

Use overrides in config to set different staleTime and gcTime per resource. The key is the first static path segment (e.g. tasks for /api/v1/tasks/{id}):

{
"input_openapi": "./openapi.json",
"output": "./src/api",
"stale_time": 30000,
"gc_time": 300000,
"overrides": {
"platforms": { "stale_time": 86400000 },
"settings": { "stale_time": 5000, "gc_time": 60000 }
}
}

Non-overridden resources use the global stale_time and gc_time values.

Use --config to point at different config files per vendor API. Both generators accept the same flag and resolve relative paths from the config file’s directory:

Terminal window
npx openapi-gen --config ./config/payments.config.json
npx openapi-react-query --config ./config/payments.config.json
npx openapi-gen --config ./config/inventory.config.json
npx openapi-react-query --config ./config/inventory.config.json

When a resource has exactly one GET with path parameters, the key factory uses the canonical detail(id) shape:

// Spec: GET /tasks/{id} only
export const taskKeys = {
all: () => ['tasks'] as const,
list: (params?) => ['tasks', 'list', params] as const,
detail: (id: string) => ['tasks', id] as const,
}

When a resource has more than one GET with path parameters (e.g. /items/{id} and /items/{id}/usage), the key factory uses operation-name-derived segments to prevent cache key collisions:

// Spec: GET /items/{id} operationId: getItemById
// GET /items/{id}/usage operationId: getItemUsage
export const itemKeys = {
all: () => ['items'] as const,
getItemById: (id: string) => ['items', 'getItemById', id] as const,
getItemUsage: (id: string) => ['items', 'getItemUsage', id] as const,
}

Without the operation-name segment both keys would resolve to ['items', id], causing React Query to treat them as the same cache entry.

Query params become required (no ?) in the generated hook signature when the spec marks any query parameter required: true. Optional params keep the params? form.

// Spec marks 'cursor' as required: true
export function useListItems(
params: Parameters<typeof listItems>[0], // required, no ?
options?: ...
)

Every generated hook types errors as ApiError. This class is generated by @codewithagents/openapi-gen into your client.ts output file:

export class ApiError extends Error {
constructor(
public readonly status: number, // HTTP status code, e.g. 422
public readonly body: unknown, // parsed JSON response body (or null)
) { ... }
}
const { data, isError, error } = useGetTask(taskId)
if (isError) {
// error is typed ApiError
console.error(error.status, error.body)
return <p>Failed to load task ({error.status})</p>
}

Pass an onError callback in options:

const create = useCreateTask({
onError: (err) => {
// err is typed ApiError
console.error('Status:', err.status)
console.error('Body:', err.body)
},
})

ApiError is the direct input to @codewithagents/api-errors, which maps backend validation errors to form field errors. Mutation errors are already typed correctly:

import { extractFieldErrors } from '@codewithagents/api-errors'
const create = useCreateTask({
onError: (err) => {
// err is ApiError; pass directly to extractFieldErrors
const fieldErrors = extractFieldErrors(err, { statusCodes: [422] })
fieldErrors.forEach(({ field, message }) => form.setError(field, { message }))
},
})

The generator produces test-utils.ts alongside hooks.ts to remove the boilerplate every hook test needs: a pre-configured QueryClient and a QueryClientProvider wrapper component. You import directly from the generated file, so there are no extra packages to install beyond your test runner and any mocking library you already use.

ExportWhat it does
createTestQueryClient()Returns a QueryClient with retry: false and gcTime: 0, the right defaults for deterministic tests
createWrapper(queryClient)Returns a React component suitable for the wrapper option in renderHook from @testing-library/react

The example below uses Vitest, React Testing Library, and MSW for HTTP mocking. Only the last two are new additions if you are not already using them.

src/tasks/__tests__/useListTasks.test.ts
import { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { createTestQueryClient, createWrapper } from '../api/test-utils'
import { useListTasks } from '../api/hooks'
import { configureClient } from '../api/client-config'
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
beforeEach(() => configureClient({ baseUrl: 'https://api.example.com' }))
it('returns tasks on success', async () => {
server.use(
http.get('https://api.example.com/api/v1/tasks', () =>
HttpResponse.json({
items: [{ id: '1', title: 'Ship it', status: 'pending' }],
total: 1,
})
)
)
const queryClient = createTestQueryClient()
const wrapper = createWrapper(queryClient)
const { result } = renderHook(() => useListTasks(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.items).toHaveLength(1)
expect(result.current.data?.items[0]?.title).toBe('Ship it')
})
src/tasks/__tests__/useCreateTask.test.ts
import { act, renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { createTestQueryClient, createWrapper } from '../api/test-utils'
import { useCreateTask } from '../api/hooks'
import { configureClient } from '../api/client-config'
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
beforeEach(() => configureClient({ baseUrl: 'https://api.example.com' }))
it('creates a task and returns the new record', async () => {
const created = { id: 'new-1', title: 'New task', status: 'pending' }
server.use(
http.post('https://api.example.com/api/v1/tasks', () =>
HttpResponse.json(created, { status: 201 })
)
)
const queryClient = createTestQueryClient()
const wrapper = createWrapper(queryClient)
const { result } = renderHook(() => useCreateTask(), { wrapper })
await act(async () => {
result.current.mutate({ title: 'New task' })
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.id).toBe('new-1')
})

The generated hooks are client-side only. They call React Query’s useQuery and useMutation, which require a browser context and a QueryClientProvider in the component tree. They will not work in React Server Components.

For SSR prefetching, use the exported key factories together with React Query’s standard HydrationBoundary approach:

// app/tasks/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
import { listTasks } from '@/api/client'
import { taskKeys } from '@/api/hooks'
import TaskList from './TaskList'
export default async function TasksPage() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: taskKeys.list(),
queryFn: () => listTasks(),
})
// If the endpoint has required query params, pass them to both taskKeys.list(params) and listTasks(params).
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TaskList />
</HydrationBoundary>
)
}
// app/tasks/TaskList.tsx ('use client')
'use client'
import { useListTasks } from '@/api/hooks'
export default function TaskList() {
const { data } = useListTasks()
return (
<ul>
{data?.items.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
)
}

The key factories ensure that the prefetched server data matches the key that the client-side hook will look up. See the TanStack Query SSR docs for the full setup.

When your spec marks an operation as deprecated: true, the generated hook gets a /** @deprecated */ JSDoc comment. TypeScript and most IDEs will show a strikethrough at the call site:

/** @deprecated */
export function useGetLegacyTask(id: string | undefined | null, options?: ...) { ... }

Types and fetch client

@codewithagents/openapi-gen generates the typed fetch client and models that these hooks wrap. Run it first.

Learn more

Server interface

@codewithagents/openapi-server generates a typed service interface and optional router from the same spec.

Learn more

Form error mapping

@codewithagents/api-errors maps ApiError responses from mutation hooks directly to form field errors.

Learn more

Check two things:

  1. auto_invalidate must be set to true in your config file.
  2. Auto-invalidation only fires for resources that have at least one GET operation in the spec. A mutation-only resource has no key factory, so no invalidation is generated.

The generated hook emits enabled: id != null && (options?.enabled ?? true) and then ...options. Because ...options is spread after the built-in enabled line, any enabled key you pass inside options overwrites the entire guard expression via the spread. Omit enabled from options (or pass enabled: undefined) to keep the null guard in effect.

Hooks do not work in React Server Components

Section titled “Hooks do not work in React Server Components”

All generated hooks use useQuery or useMutation internally and require a browser context plus a QueryClientProvider ancestor. Add 'use client' to the component file, or prefetch on the server using the key factories and HydrationBoundary (see SSR and Next.js App Router above).