Skip to content

05Project

Svelte Query example

The task list of the React Query example, built with Svelte 5 (runes) and TanStack Svelte Query 6, from the same OpenAPI 3.1 document (openapi.yaml).

Terminal window
pnpm example:svelte-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 (svelte-check) and builds it

What to look at:

  • swaggerz.config.ts enables Svelte Query functions (features.query.framework: 'svelte'), an infinite list for listTasks paginated by its cursor parameter and invalidation of every Tasks query after mutations.
  • src/Tasks.svelte passes accessors to createListTasksInfiniteQuery: reading the $state filter inside them makes the query follow it. src/TaskDetails.svelte does the same with a prop and uses select. Errors are typed: ApiError with the operation’s error body (Problem) or RequestError.
  • 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: {
query: {
framework: 'svelte',
// 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'] },
},
},
});

Sources

src/App.svelte
<script lang="ts">
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
import Tasks from './Tasks.svelte';
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 10_000 } } });
</script>
<QueryClientProvider client={queryClient}>
<Tasks />
</QueryClientProvider>
src/NewTaskForm.svelte
<script lang="ts">
import { createCreateTaskMutation } from './generated/api/svelte-query';
import { errorMessage } from './errors.ts';
let title = $state('');
// `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 } }, { onSuccess: () => (title = '') });
}
</script>
<form onsubmit={submit}>
<input bind:value={title} name="title" placeholder="New task" />
<button disabled={createTask.isPending}>Add</button>
{#if createTask.error}<p role="alert">{errorMessage(createTask.error)}</p>{/if}
</form>
src/TaskDetails.svelte
<script lang="ts">
import { createGetTaskQuery } from './generated/api/svelte-query';
import { errorMessage } from './errors.ts';
let { taskId }: { taskId: string } = $props();
// 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 }),
() => ({ select: (task) => new Date(task.createdAt).toLocaleString() }),
);
const notes = createGetTaskQuery(
() => ({ taskId }),
() => ({ select: (task) => task.notes ?? 'No notes' }),
);
</script>
{#if created.error}
<p role="alert">{errorMessage(created.error)}</p>
{:else}
<aside>
<h2>Details</h2>
<p>Created: {created.data ?? '…'}</p>
<p>{notes.data}</p>
</aside>
{/if}
src/TaskItem.svelte
<script lang="ts">
import type { Task, TaskStatus } from './generated/api';
import {
createDeleteTaskMutation,
createUpdateTaskMutation,
} from './generated/api/svelte-query';
let { task, onselect }: { task: Task; onselect: () => void } = $props();
const updateTask = createUpdateTaskMutation();
const deleteTask = createDeleteTaskMutation();
const next: TaskStatus = $derived(task.status === 'todo' ? 'doing' : 'done');
</script>
<li>
<button onclick={onselect}>{task.title}</button> <small>{task.status}</small>
{#if task.status !== 'done'}
<button onclick={() => updateTask.mutate({ taskId: task.id, body: { status: next } })}>
→ {next}
</button>
{/if}
<button onclick={() => deleteTask.mutate({ taskId: task.id })}>Delete</button>
</li>
src/Tasks.svelte
<script lang="ts">
import type { TaskStatus } from './generated/api';
import { createListTasksInfiniteQuery } from './generated/api/svelte-query';
import { errorMessage } from './errors.ts';
import NewTaskForm from './NewTaskForm.svelte';
import TaskDetails from './TaskDetails.svelte';
import TaskItem from './TaskItem.svelte';
const STATUSES: readonly TaskStatus[] = ['todo', 'doing', 'done'];
let status = $state<TaskStatus | undefined>(undefined);
let selected = $state<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, limit: 2 }),
() => ({ initialPageParam: undefined, getNextPageParam: (page) => page.nextCursor }),
);
function setStatus(event: Event) {
const value = (event.currentTarget as HTMLSelectElement).value;
status = STATUSES.find((s) => s === value);
}
</script>
<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={setStatus}>
<option value="">all</option>
{#each STATUSES as s (s)}
<option value={s}>{s}</option>
{/each}
</select>
</p>
{#if tasks.isPending}<p>Loading…</p>{/if}
{#if tasks.error}<p role="alert">{errorMessage(tasks.error)}</p>{/if}
<ul>
{#each tasks.data?.pages.flatMap((page) => page.items) ?? [] as task (task.id)}
<TaskItem {task} onselect={() => (selected = task.id)} />
{/each}
</ul>
{#if tasks.hasNextPage}
<button disabled={tasks.isFetchingNextPage} onclick={() => tasks.fetchNextPage()}>
{tasks.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
{/if}
{#if selected !== undefined}<TaskDetails taskId={selected} />{/if}
</main>
src/errors.ts
import { isApiError, type OperationError } from './generated/api';
/** A readable message for any error an operation can reject with. */
export 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}`;
}
src/main.ts
import { mount } from 'svelte';
import App from './App.svelte';
const target = document.getElementById('root');
if (target === null) throw new Error('Missing #root element.');
mount(App, { target });

Generated files

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

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

The project on GitHub