05Project
Solid Query example
The task list of the React Query example, built with Solid and TanStack Solid Query, from the same OpenAPI 3.1 document (openapi.yaml).
pnpm example:solid-query # from the repository root: build swaggerz, generate, start ViteOr, inside this directory once swaggerz is built:
pnpm generate # src/generated/apipnpm dev # http://localhost:5173, the API served by the generated mocks (src/mocks.ts)pnpm check # type-checks the app and builds itWhat to look at:
- swaggerz.config.ts enables Solid Query primitives (
features.query.framework: 'solid'), an infinite list forlistTaskspaginated by itscursorparameter and invalidation of everyTasksquery after mutations. - src/App.tsx passes accessors to
createListTasksInfiniteQueryandcreateGetTaskQuery: reading signals and props inside them makes the queries follow them. Errors are typed:ApiErrorwith the operation’s error body (Problem) orRequestError. - src/mocks.ts serves the API in the browser with the generated Mock Service Worker handlers: fixtures (
makeTask) seed an in-memory list that the handlers read and change. src/index.tsx passes those handlers to the generatedstartMocks()during development only (one line:if (import.meta.env.DEV) await startMocks({ handlers })); vite.config.ts serves the worker script with MSW’s Vite plugin. It is typed with the generated models, so it stops compiling when the specification changes. See mocks.
Config
import { defineConfig } from 'swaggerz';
export default defineConfig({ spec: { version: '3.1', input: './openapi.yaml' }, output: { directory: './src/generated/api' }, client: { baseUrl: '/api' }, features: { query: { framework: 'solid', // Load more tasks with `createListTasksInfiniteQuery`, driven by the `cursor` parameter. infinite: { listTasks: { pageParam: 'cursor' } }, // Refresh every task query after a change. invalidates: { createTask: ['Tasks'], updateTask: ['Tasks'], deleteTask: ['Tasks'] }, }, // Mock Service Worker handlers and fixtures: src/mocks.ts serves the API from them. mocks: 'msw', },});Sources
import { createSignal, For, Show } from 'solid-js';import { isApiError, type OperationError, type Task, type TaskStatus } from './generated/api';import { createCreateTaskMutation, createDeleteTaskMutation, createGetTaskQuery, createListTasksInfiniteQuery, createUpdateTaskMutation,} from './generated/api/solid-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 the client could not parse. return `${error.kind}: ${error.message}`;}
export function App() { const [status, setStatus] = createSignal<TaskStatus | undefined>(undefined); const [selected, setSelected] = createSignal<string | undefined>(undefined);
// Parameters are accessors read reactively: changing the filter refetches, and every filter is // cached separately because every parameter is part of the query key. const tasks = createListTasksInfiniteQuery( () => ({ status: status(), limit: 2 }), () => ({ initialPageParam: undefined, getNextPageParam: (page) => page.nextCursor }), );
return ( <main style={{ 'font-family': 'system-ui', 'max-width': '640px', margin: '2rem auto', padding: '0 1rem', }} > <h1>Tasks</h1> <NewTaskForm /> <p> Show:{' '} <select name="status" value={status() ?? ''} onChange={(event) => setStatus(STATUSES.find((s) => s === event.currentTarget.value))} > <option value="">all</option> <For each={STATUSES}>{(s) => <option value={s}>{s}</option>}</For> </select> </p> <Show when={tasks.isPending}> <p>Loading…</p> </Show> <Show when={tasks.error}>{(error) => <p role="alert">{errorMessage(error())}</p>}</Show> <ul> <For each={tasks.data?.pages.flatMap((page) => page.items) ?? []}> {(task) => <TaskItem task={task} onSelect={() => setSelected(task.id)} />} </For> </ul> <Show when={tasks.hasNextPage}> <button disabled={tasks.isFetchingNextPage} onClick={() => void tasks.fetchNextPage()}> {tasks.isFetchingNextPage ? 'Loading…' : 'Load more'} </button> </Show> <Show when={selected()}>{(taskId) => <TaskDetails taskId={taskId()} />}</Show> </main> );}
function NewTaskForm() { const [title, setTitle] = createSignal(''); // `invalidates` in swaggerz.config.ts refreshes the task lists after the mutation succeeds. const createTask = createCreateTaskMutation();
function submit(event: SubmitEvent) { event.preventDefault(); createTask.mutate({ body: { title: title() } }, { onSuccess: () => setTitle('') }); }
return ( <form onSubmit={submit}> <input name="title" value={title()} onInput={(event) => setTitle(event.currentTarget.value)} placeholder="New task" /> <button disabled={createTask.isPending}>Add</button> <Show when={createTask.error}>{(error) => <p role="alert">{errorMessage(error())}</p>}</Show> </form> );}
function TaskItem(props: { task: Task; onSelect: () => void }) { const updateTask = createUpdateTaskMutation(); const deleteTask = createDeleteTaskMutation(); const next = (): TaskStatus => (props.task.status === 'todo' ? 'doing' : 'done');
return ( <li> <button onClick={() => props.onSelect()}>{props.task.title}</button>{' '} <small>{props.task.status}</small>{' '} <Show when={props.task.status !== 'done'}> <button onClick={() => updateTask.mutate({ taskId: props.task.id, body: { status: next() } })} > → {next()} </button> </Show> <button onClick={() => deleteTask.mutate({ taskId: props.task.id })}>Delete</button> </li> );}
function TaskDetails(props: { taskId: string }) { // The accessor reads the prop, so the query follows it; `select` narrows the cached data and // its result type is inferred. const created = createGetTaskQuery( () => ({ taskId: props.taskId }), () => ({ select: (task) => new Date(task.createdAt).toLocaleString() }), ); const notes = createGetTaskQuery( () => ({ taskId: props.taskId }), () => ({ select: (task) => task.notes ?? 'No notes' }), );
return ( <Show when={!created.error} fallback={<p role="alert">{errorMessage(created.error!)}</p>}> <aside> <h2>Details</h2> <p>Created: {created.data ?? '…'}</p> <p>{notes.data}</p> </aside> </Show> );}import { QueryClient, QueryClientProvider } from '@tanstack/solid-query';import { render } from 'solid-js/web';import { App } from './App.tsx';import { startMocks } from './generated/api/mocks/start.ts';
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 10_000 } } });
// During development, the generated mocks answer the API (with the handlers of src/mocks.ts// first); render once they are ready. Production builds contain neither MSW nor the mocks.if (import.meta.env.DEV) await startMocks({ handlers: () => import('./mocks.ts').then((mocks) => mocks.handlers) });
const root = document.getElementById('root');if (root === null) throw new Error('Missing #root element.');render( () => ( <QueryClientProvider client={queryClient}> <App /> </QueryClientProvider> ), root,);/** * The Tasks API in the browser, made of the generated Mock Service Worker handlers: fixtures seed * an in-memory list that the handlers read and change. `index.tsx` passes them to the generated * `startMocks()` during development. The handlers and fixtures are typed with the generated * models, so this file stops compiling when the specification changes. */import { HttpResponse } from 'msw';import type { Problem, Task } from './generated/api';import { makeTask, mockCreateTask, mockDeleteTask, mockGetTask, mockListTasks, mockUpdateTask,} from './generated/api/mocks';
const tasks: Task[] = [ makeTask({ id: '3', title: 'Ship the release', status: 'todo', notes: null, createdAt: '2026-01-03T10:00:00Z', }), makeTask({ id: '2', title: 'Write the changelog', status: 'doing', notes: 'Group by feature', createdAt: '2026-01-02T10:00:00Z', }), makeTask({ id: '1', title: 'Generate the client', status: 'done', notes: null, createdAt: '2026-01-01T10:00:00Z', }),];let nextId = tasks.length + 1;
/** A little latency, so loading states are visible. */const latency = { delay: 300 };
/** An error response the handler does not send by default (a body function can return one). */function problem(status: 404 | 422, title: string, detail?: string): Response { const body: Problem = { title, ...(detail === undefined ? {} : { detail }) }; return HttpResponse.json(body, { status });}
const indexOf = (id: string): number => tasks.findIndex((task) => task.id === id);
export const handlers = [ mockListTasks({ ...latency, body: ({ query }) => { const status = query.get('status'); const limit = Number(query.get('limit') ?? '10'); const start = Number(query.get('cursor') ?? '0'); const matching = tasks.filter((task) => status === null || task.status === status); const end = start + limit; return { items: matching.slice(start, end), ...(end < matching.length ? { nextCursor: String(end) } : {}), }; }, }), mockCreateTask({ ...latency, body: async ({ body }) => { const input = await body(); if (input.title.trim() === '') return problem(422, 'Invalid task', 'The title must not be empty.'); const task = makeTask({ id: String(nextId++), title: input.title, status: 'todo', notes: input.notes ?? null, createdAt: new Date().toISOString(), }); tasks.unshift(task); return task; }, }), mockGetTask({ ...latency, body: ({ params }) => tasks[indexOf(params.taskId)] ?? problem(404, 'Not found'), }), mockUpdateTask({ ...latency, body: async ({ params, body }) => { const index = indexOf(params.taskId); const task = tasks[index]; if (task === undefined) return problem(404, 'Not found'); const updated: Task = { ...task, ...(await body()) }; tasks[index] = updated; return updated; }, }), mockDeleteTask({ ...latency, body: ({ params }) => { const index = indexOf(params.taskId); if (index === -1) return problem(404, 'Not found'); tasks.splice(index, 1); return undefined; }, }),];Generated files
43 files for 5 operations and 6 models, generated with this config when the site was built:
client.ts, index.tsmocks/16 filesmodels/6 filesruntime/7 filessolid-query/7 filestasks/5 files