Forms / vee-validate
Vue forms combine v-model (two-way binding), schema-based validation (vee-validate / VueUse / Zod adapters), and accessibility primitives. The pattern that scales: a small composable per form that exposes typed state, validation errors, and a submit handler — components just render.
Reactive form with Zod validation + accessibility
EXAMPLE
<script setup lang='ts'>
import { reactive, computed } from 'vue';
import { z } from 'zod';
// 1) Schema — single source of truth for shape + validation
const Schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'At least 8 characters'),
confirm: z.string(),
agree: z.literal(true, { errorMap: () => ({ message: 'Required' }) }),
}).refine((d) => d.password === d.confirm, {
message: 'Passwords do not match',
path: ['confirm'],
});
type FormValues = z.input<typeof Schema>;
type FormErrors = Partial<Record<keyof FormValues | 'form', string>>;
// 2) State
const values = reactive<FormValues>({
email: '', password: '', confirm: '', agree: false as any,
});
const errors = reactive<FormErrors>({});
const submitting = reactive({ value: false });
// 3) Field validation — lazy + on blur
function validateField(field: keyof FormValues) {
const result = Schema.safeParse(values);
if (result.success) { delete (errors as any)[field]; return; }
const issue = result.error.issues.find((i) => i.path[0] === field);
errors[field] = issue?.message ?? undefined;
}
async function onSubmit() {
const result = Schema.safeParse(values);
if (!result.success) {
for (const issue of result.error.issues) errors[issue.path[0] as keyof FormErrors] = issue.message;
return;
}
submitting.value = true;
try {
const res = await fetch('/api/signup', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify(result.data),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
errors.form = body?.error ?? 'Signup failed';
return;
}
// success: redirect or reset
Object.assign(values, { email: '', password: '', confirm: '', agree: false });
} finally {
submitting.value = false;
}
}
const submitDisabled = computed(() => submitting.value);
</script>
<template>
<form @submit.prevent='onSubmit' novalidate class='space-y-3'>
<div v-if='errors.form'
role='alert'
class='rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700'>
{{ errors.form }}
</div>
<label class='block'>
<span class='text-sm'>Email</span>
<input v-model='values.email'
@blur='validateField("email")'
type='email'
autocomplete='email'
:aria-invalid='!!errors.email'
:aria-describedby='errors.email ? "email-err" : undefined'
class='input' />
<small v-if='errors.email' id='email-err' class='text-red-600'>{{ errors.email }}</small>
</label>
<label class='block'>
<span class='text-sm'>Password</span>
<input v-model='values.password'
@blur='validateField("password")'
type='password'
autocomplete='new-password'
:aria-invalid='!!errors.password'
class='input' />
<small v-if='errors.password' class='text-red-600'>{{ errors.password }}</small>
</label>
<label class='block'>
<span class='text-sm'>Confirm</span>
<input v-model='values.confirm'
@blur='validateField("confirm")'
type='password'
autocomplete='new-password'
:aria-invalid='!!errors.confirm'
class='input' />
<small v-if='errors.confirm' class='text-red-600'>{{ errors.confirm }}</small>
</label>
<label class='inline-flex items-center gap-2'>
<input v-model='values.agree' type='checkbox' />
<span class='text-sm'>I agree to the terms</span>
</label>
<small v-if='errors.agree' class='block text-red-600'>{{ errors.agree }}</small>
<button :disabled='submitDisabled' class='btn'>{{ submitting.value ? 'Saving...' : 'Sign up' }}</button>
</form>
</template>
<!-- ===== Patterns to internalise =====
1) Zod schema -> shape + validation in one place; derive types via z.input/z.output
2) Validate per-field on blur; validate the whole form on submit
3) Server-side errors land in errors.form for global display
4) aria-invalid + aria-describedby + role='alert' = screen-reader friendly
5) :disabled on submit while in flight; never lock the form to ONE state
===== Alternative: use vee-validate with the Zod resolver =====
import { useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
const { handleSubmit, defineField, errors } = useForm({
validationSchema: toTypedSchema(Schema),
});
const [email, emailAttrs] = defineField('email');
const [password, passwordAttrs] = defineField('password');
// ...
const onSubmit = handleSubmit(async (values) => {
await fetch('/api/signup', { method: 'POST', body: JSON.stringify(values) });
});
===== Pitfalls =====
- v-model on a checkbox without :true-value / :false-value when you need string semantics
- Forgetting to mark .prevent on @submit -> page reloads
- Showing every error on first paint (validate ON BLUR, not on mount)
- No autocomplete attributes -> password managers fight your form
- Missing aria-invalid -> screen readers say nothing
-->
Why it matters
Define the validation schema once and derive both the validator and the TypeScript types from it. The form becomes "this is the contract; render fields that fit it; submit when the schema is happy" — no duplicate logic between client and server validation, no drift when the contract changes.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// vee-validate + yup for great DX
import { useForm } from 'vee-validate';
const { handleSubmit } = useForm({ ... });
Try it Yourself »
Discussion
Loading…