Skip to content

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).

Terminal window
pnpm example:solid-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, the API served by the generated mocks (src/mocks.ts)
pnpm check # type-checks the app and builds it

What to look at:

  • swaggerz.config.ts enables Solid Query primitives (features.query.framework: 'solid'), an infinite list for listTasks paginated by its cursor parameter and invalidation of every Tasks query after mutations.
  • src/App.tsx passes accessors to createListTasksInfiniteQuery and createGetTaskQuery: reading signals and props inside them makes the queries follow them. Errors are typed: ApiError with the operation’s error body (Problem) or RequestError.
  • 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 generated startMocks() 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

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: {
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

src/App.tsx
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>
);
}
src/index.tsx
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,
);
src/mocks.ts
/**
* 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.ts
  • mocks/16 files
  • models/6 files
  • runtime/7 files
  • solid-query/7 files
  • tasks/5 files

The project on GitHub