Forms and Validation
The create and edit flows: one schema, server errors mapped onto fields, and an optimistic update that rolls back on failure.
schema.safeParse(value) returns { success: true, data } or { success: false, error }Create and edit are the same form with different starting values, so they are one component. It takes the initial values as a prop, validates on submit, and emits the finished draft. It never saves anything itself, which is what lets the same component serve both routes.
One schema, two checks
A schema is a description of valid data that can also check a value. Calling safeParse returns { success: true, data } or { success: false, error }, and error.issues is an array where each issue has a path and a message. Walking that array fills a plain object keyed by field name, which the template reads.
The server validates again, because a browser check is a convenience and not a guarantee. When it rejects a save it answers 422 with per-field messages; the API client puts those on ApiError.fields, and the view passes them back down as a prop so they appear under the same inputs.
Optimistic updates
Toggling a task changes the store first and calls the server after. The old value is kept in a local variable, and the catch block puts it back before rethrowing. Without that rollback the list quietly disagrees with the database.
What trips people
useId, added in Vue 3.5, generates the id that ties a label to its input, so two instances of the form on one page do not collide.
Example
<script setup lang="ts">
import { reactive, ref, useId } from 'vue'
import { z } from 'zod'
import type { TaskDraft } from '@/types/task'
type FieldErrors = Partial<Record<keyof TaskDraft, string>>
const props = defineProps<{ initial: TaskDraft; pending: boolean; serverErrors?: FieldErrors }>()
const emit = defineEmits<{ submit: [draft: TaskDraft] }>()
const schema = z.object({
title: z.string().min(1, 'Title is required'),
notes: z.string().max(500, 'Notes are too long'),
})
const draft = reactive<TaskDraft>({ ...props.initial })
const errors = ref<FieldErrors>({})
const titleId = useId()
const notesId = useId()
function submit() {
const result = schema.safeParse(draft)
errors.value = {}
if (!result.success) {
for (const issue of result.error.issues) {
errors.value[issue.path[0] as keyof TaskDraft] = issue.message
}
return
}
emit('submit', { ...draft })
}
</script>
<template>
<form novalidate @submit.prevent="submit">
<label :for="titleId">Title</label>
<input :id="titleId" v-model="draft.title" />
<p v-if="errors.title ?? props.serverErrors?.title">
{{ errors.title ?? props.serverErrors?.title }}
</p>
<label :for="notesId">Notes</label>
<textarea :id="notesId" v-model="draft.notes"></textarea>
<p v-if="errors.notes">{{ errors.notes }}</p>
<button type="submit" :disabled="props.pending">Save</button>
</form>
</template>
<style scoped>
form {
display: grid;
gap: 0.5rem;
max-width: 24rem;
}
</style>When to use it
- Serving the create and edit routes from one form component.
- Showing a server's 422 messages under the inputs they belong to.
- Keeping a checkbox responsive while the request is in flight, and undoing it when the request fails.
- Pairing every label with its input when the same form appears twice on a page.
More examples
Turning a rejected save into field errors
One function decides whether a failure belongs under an input or at the top of the form. A 422 carries per-field messages, so they go to the fields; anything else is a message the user cannot act on field by field. Reach for this wherever a form calls the API, so every form reports failures the same way.
// src/forms/serverErrors.ts
import { ApiError } from '@/api/client'
export interface MappedErrors {
fields: Record<string, string>
message: string
}
export function mapServerErrors(error: unknown): MappedErrors {
if (error instanceof ApiError && error.status === 422) {
return { fields: error.fields, message: '' }
}
return { fields: {}, message: 'Could not save. Try again.' }
}
const rejected = new ApiError('Invalid', 422, { title: 'Already used' })
// mapServerErrors(rejected).fields.title // Output: Already used
// mapServerErrors(rejected).message // Output:
const offline = new ApiError('Bad gateway', 502)
// mapServerErrors(offline).message // Output: Could not save. Try again.
// Object.keys(mapServerErrors(offline).fields).length // Output: 0An optimistic toggle with a rollback
The store writes the new value immediately so the checkbox never lags, remembers the previous value, and restores it if the request fails before rethrowing so the caller can still report the failure. Reach for this on small reversible changes; for a create or a delete, wait for the server instead.
// src/stores/tasks.ts (inside the defineStore setup function)
async function toggle(id: string, done: boolean): Promise<void> {
const task = items.value.find((item) => item.id === id)
if (!task) return
const previous = task.done
task.done = done
try {
await request<Task>(`/tasks/${id}`, {
method: 'PATCH',
body: JSON.stringify({ done }),
})
} catch (cause) {
task.done = previous
throw cause
}
}
async function create(draft: TaskDraft): Promise<Task> {
const created = await request<Task>('/tasks', {
method: 'POST',
body: JSON.stringify(draft),
})
items.value = [created, ...items.value]
return created
}The create route that owns the saving
The view holds the pending flag, calls the store, routes to the new task on success and feeds mapped field errors back into the form. The form stays free of network code, which is why the same component can be mounted by the edit route with the existing task as its initial prop.
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import TaskForm from '@/components/TaskForm.vue'
import { mapServerErrors } from '@/forms/serverErrors'
import { useTasksStore } from '@/stores/tasks'
import type { TaskDraft } from '@/types/task'
const router = useRouter()
const tasks = useTasksStore()
const pending = ref(false)
const message = ref('')
const fieldErrors = ref<Record<string, string>>({})
async function save(draft: TaskDraft) {
pending.value = true
message.value = ''
fieldErrors.value = {}
try {
const created = await tasks.create(draft)
await router.push({ name: 'task-detail', params: { id: created.id } })
} catch (error) {
const mapped = mapServerErrors(error)
message.value = mapped.message
fieldErrors.value = mapped.fields
} finally {
pending.value = false
}
}
</script>
<template>
<section>
<h1>New task</h1>
<p v-if="message">{{ message }}</p>
<task-form
:initial="{ title: '', notes: '' }"
:pending="pending"
:server-errors="fieldErrors"
@submit="save"
/>
</section>
</template>
<style scoped>
section {
max-width: 32rem;
}
</style>
Discussion