Skip to content

05Project

Vue Query example

The task list of the React Query example, built with Vue 3 single-file components, TanStack Vue Query and Valibot validation, from the same OpenAPI 3.1 document (openapi.yaml).

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

What to look at:

  • swaggerz.config.ts enables Vue Query composables (features.query.framework: 'vue'), an infinite list for listTasks paginated by its cursor parameter, invalidation of every Tasks query after mutations, and Valibot validation of responses.
  • src/App.vue passes a getter as the parameters of useListTasksInfinite, so changing the filter refetches; src/TaskDetails.vue 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: 'vue',
// 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 with Valibot.
validation: 'valibot',
},
});

Sources

src/App.vue
<script setup lang="ts">
import { ref } from 'vue';
import type { TaskStatus } from './generated/api';
import { useListTasksInfinite } from './generated/api/vue-query';
import { errorMessage } from './errors.ts';
import NewTaskForm from './NewTaskForm.vue';
import TaskDetails from './TaskDetails.vue';
import TaskItem from './TaskItem.vue';
const STATUSES: readonly TaskStatus[] = ['todo', 'doing', 'done'];
const status = ref<TaskStatus | undefined>(undefined);
const selected = ref<string | undefined>(undefined);
// Parameters may be getters: changing the filter refetches, and every filter is cached
// separately because every parameter is part of the query key.
const tasks = useListTasksInfinite(() => ({ status: status.value, limit: 2 }), {
initialPageParam: undefined,
getNextPageParam: (page) => page.nextCursor,
});
function setStatus(event: Event) {
const value = (event.target as HTMLSelectElement).value;
status.value = STATUSES.find((s) => s === value);
}
</script>
<template>
<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 ?? ''" @change="setStatus">
<option value="">all</option>
<option v-for="s in STATUSES" :key="s" :value="s">{{ s }}</option>
</select>
</p>
<p v-if="tasks.isPending.value">Loading…</p>
<p v-if="tasks.error.value" role="alert">{{ errorMessage(tasks.error.value) }}</p>
<ul>
<template v-for="page in tasks.data.value?.pages ?? []">
<TaskItem
v-for="task in page.items"
:key="task.id"
:task="task"
@select="selected = task.id"
/>
</template>
</ul>
<button
v-if="tasks.hasNextPage.value"
:disabled="tasks.isFetchingNextPage.value"
@click="tasks.fetchNextPage()"
>
{{ tasks.isFetchingNextPage.value ? 'Loading…' : 'Load more' }}
</button>
<TaskDetails v-if="selected !== undefined" :task-id="selected" />
</main>
</template>
src/NewTaskForm.vue
<script setup lang="ts">
import { ref } from 'vue';
import { useCreateTask } from './generated/api/vue-query';
import { errorMessage } from './errors.ts';
const title = ref('');
// `invalidates` in swaggerz.config.ts refreshes the task lists after the mutation succeeds.
const createTask = useCreateTask();
function submit() {
createTask.mutate({ body: { title: title.value } }, { onSuccess: () => (title.value = '') });
}
</script>
<template>
<form @submit.prevent="submit">
<input v-model="title" name="title" placeholder="New task" />
<button :disabled="createTask.isPending.value">Add</button>
<p v-if="createTask.error.value" role="alert">{{ errorMessage(createTask.error.value) }}</p>
</form>
</template>
src/TaskDetails.vue
<script setup lang="ts">
import { useGetTask } from './generated/api/vue-query';
import { errorMessage } from './errors.ts';
const props = defineProps<{ taskId: string }>();
// A getter keeps the query in step with the prop; `select` narrows the cached data and its
// result type is inferred.
const created = useGetTask(() => ({ taskId: props.taskId }), {
select: (task) => new Date(task.createdAt).toLocaleString(),
});
const notes = useGetTask(() => ({ taskId: props.taskId }), {
select: (task) => task.notes ?? 'No notes',
});
</script>
<template>
<p v-if="created.error.value" role="alert">{{ errorMessage(created.error.value) }}</p>
<aside v-else>
<h2>Details</h2>
<p>Created: {{ created.data.value ?? '…' }}</p>
<p>{{ notes.data.value }}</p>
</aside>
</template>
src/TaskItem.vue
<script setup lang="ts">
import { computed } from 'vue';
import type { Task, TaskStatus } from './generated/api';
import { useDeleteTask, useUpdateTask } from './generated/api/vue-query';
const props = defineProps<{ task: Task }>();
defineEmits<{ select: [] }>();
const updateTask = useUpdateTask();
const deleteTask = useDeleteTask();
const next = computed<TaskStatus>(() => (props.task.status === 'todo' ? 'doing' : 'done'));
</script>
<template>
<li>
<button @click="$emit('select')">{{ task.title }}</button> <small>{{ task.status }}</small>
<button
v-if="task.status !== 'done'"
@click="updateTask.mutate({ taskId: task.id, body: { status: next } })"
>
→ {{ next }}
</button>
<button @click="deleteTask.mutate({ taskId: task.id })">Delete</button>
</li>
</template>
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 that failed Valibot validation.
return `${error.kind}: ${error.message}`;
}
src/main.ts
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
import { createApp } from 'vue';
import App from './App.vue';
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 10_000 } } });
createApp(App).use(VueQueryPlugin, { queryClient }).mount('#root');

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
  • runtime/7 files
  • tasks/5 files
  • valibot/7 files
  • vue-query/7 files

The project on GitHub