Skip to content

05Project

React Query example

A task list built with React, TanStack Query and a client generated from an OpenAPI 3.1 document (openapi.yaml).

Terminal window
pnpm example:react-query # from the repository root: build swaggerz, generate, start Vite

Or, inside this directory once swaggerz is built:

Terminal window
pnpm generate # src/generated/api
pnpm dev # http://localhost:5173, served by an in-memory mock API (mock-api.ts)
pnpm check # type-checks the app and builds it
pnpm dashboard # explore the API and the generated code

What to look at:

  • swaggerz.config.ts enables React Query hooks, an infinite query for listTasks paginated by its cursor parameter, invalidation of every Tasks query after mutations, and Zod validation of responses.
  • src/App.tsx uses useListTasksInfinite, useGetTask with select, and useCreateTask/useUpdateTask/useDeleteTask. Errors are typed: ApiError with the operation’s error body (Problem) or RequestError (network, timeout, or a response that failed validation).
  • mock-api.ts implements the API with the generated model types, so it stops compiling when the specification changes.

Config

swaggerz.config.ts
import { defineConfig } from 'swaggerz';
export default defineConfig({
spec: { version: '3.1', input: './openapi.yaml' },
output: { directory: './src/generated/api' },
client: { baseUrl: '/api' },
features: {
reactQuery: {
// Load more tasks with `useListTasksInfinite`, driven by the `cursor` query parameter.
infinite: { listTasks: { pageParam: 'cursor' } },
// Refresh every task query after a change.
invalidates: { createTask: ['Tasks'], updateTask: ['Tasks'], deleteTask: ['Tasks'] },
},
// Check responses at runtime against the specification.
validation: 'zod',
},
});

Sources

src/App.tsx
import { useState, type FormEvent } from 'react';
import { isApiError, type OperationError, type Task, type TaskStatus } from './generated/api';
import {
useCreateTask,
useDeleteTask,
useGetTask,
useListTasksInfinite,
useUpdateTask,
} from './generated/api/react-query';
const STATUSES: readonly TaskStatus[] = ['todo', 'doing', 'done'];
/** A readable message for any error an operation can reject with. */
function errorMessage(error: OperationError<unknown>): string {
if (isApiError(error)) {
const body = error.body as { title?: unknown } | undefined;
return typeof body?.title === 'string' ? body.title : `HTTP ${error.status}`;
}
// RequestError: network failure, timeout, or a response that failed Zod validation.
return `${error.kind}: ${error.message}`;
}
export function App() {
const [status, setStatus] = useState<TaskStatus | undefined>(undefined);
const [selected, setSelected] = useState<string | undefined>(undefined);
// Every parameter is part of the query key, so each filter is cached separately.
const tasks = useListTasksInfinite(
{ status, limit: 2 },
{ initialPageParam: undefined, getNextPageParam: (page) => page.nextCursor },
);
return (
<main
style={{ fontFamily: 'system-ui', maxWidth: 640, margin: '2rem auto', padding: '0 1rem' }}
>
<h1>Tasks</h1>
<NewTaskForm />
<p>
Show:{' '}
<select
name="status"
value={status ?? ''}
onChange={(event) => {
const value = STATUSES.find((s) => s === event.target.value);
setStatus(value);
}}
>
<option value="">all</option>
{STATUSES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</p>
{tasks.isPending && <p>Loading…</p>}
{tasks.isError && <p role="alert">{errorMessage(tasks.error)}</p>}
<ul>
{tasks.data?.pages
.flatMap((page) => page.items)
.map((task) => (
<TaskItem key={task.id} task={task} onSelect={() => setSelected(task.id)} />
))}
</ul>
{tasks.hasNextPage && (
<button disabled={tasks.isFetchingNextPage} onClick={() => void tasks.fetchNextPage()}>
{tasks.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
)}
{selected !== undefined && <TaskDetails taskId={selected} />}
</main>
);
}
function NewTaskForm() {
const [title, setTitle] = useState('');
// `invalidates` in swaggerz.config.ts refreshes the task lists after the mutation succeeds.
const createTask = useCreateTask();
function submit(event: FormEvent) {
event.preventDefault();
createTask.mutate({ body: { title } }, { onSuccess: () => setTitle('') });
}
return (
<form onSubmit={submit}>
<input
name="title"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="New task"
/>
<button disabled={createTask.isPending}>Add</button>
{createTask.isError && <p role="alert">{errorMessage(createTask.error)}</p>}
</form>
);
}
function TaskItem({ task, onSelect }: { task: Task; onSelect: () => void }) {
const updateTask = useUpdateTask();
const deleteTask = useDeleteTask();
const next: TaskStatus = task.status === 'todo' ? 'doing' : 'done';
return (
<li>
<button onClick={onSelect}>{task.title}</button> <small>{task.status}</small>{' '}
{task.status !== 'done' && (
<button onClick={() => updateTask.mutate({ taskId: task.id, body: { status: next } })}>
→ {next}
</button>
)}
<button onClick={() => deleteTask.mutate({ taskId: task.id })}>Delete</button>
</li>
);
}
function TaskDetails({ taskId }: { taskId: string }) {
// `select` narrows the cached data; its result type is inferred.
const created = useGetTask(
{ taskId },
{ select: (task) => new Date(task.createdAt).toLocaleString() },
);
const notes = useGetTask({ taskId }, { select: (task) => task.notes ?? 'No notes' });
if (created.isError) return <p role="alert">{errorMessage(created.error)}</p>;
return (
<aside>
<h2>Details</h2>
<p>Created: {created.data ?? '…'}</p>
<p>{notes.data}</p>
</aside>
);
}
src/main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 10_000 } } });
const root = document.getElementById('root');
if (root === null) throw new Error('Missing #root element.');
createRoot(root).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);

Generated files

34 files for 5 operations and 6 models, generated with this config when the site was built:

  • client.ts, index.ts
  • models/6 files
  • react-query/7 files
  • runtime/7 files
  • tasks/5 files
  • zod/7 files

The project on GitHub