Validating a Form
Validate on blur with a Zod or Valibot schema, show one message per field, and check everything again on submit.
const result = schema.safeParse(values) // { success, data } | { success, error }Validation answers two questions: is this value usable, and when do I say so. The second question is the one that makes forms unpleasant. Checking on every keystroke tells someone their email address is invalid while they are typing the first letter of it.
When to check
Validate a field when it loses focus, and validate the whole form again on submit. Track a touched flag per field and show a message only once that field has been left. On submit, mark every field touched, so a user who pressed the button without typing sees all the messages at once.
A schema instead of if-statements
A schema describes the shape of the valid data in one place. Zod's safeParse returns { success: true, data } or { success: false, error }, and error.issues is an array of objects carrying message and path. path[0] is the field name, which is how you move a message next to its input. Valibot works the same way with v.safeParse(schema, input), returning success, output and issues; v.flatten groups those issues by field path.
One message per field
A single value can break several rules at once, so issues can hold three entries for one input. Keep the first message per field. Leave the browser's own checks in place too: keep required and type="email" on the input for the keyboard and the screen reader, and add novalidate to the form so you control when messages appear.
Example
<!-- src/components/SignupForm.vue -->
<script setup lang="ts">
import { reactive, ref } from 'vue'
import * as z from 'zod'
const schema = z.object({
email: z.email('Enter a valid email address'),
password: z.string().min(8, 'Use at least 8 characters')
})
type FieldName = 'email' | 'password'
const form = reactive({ email: '', password: '' })
const touched = reactive<Record<FieldName, boolean>>({ email: false, password: false })
const errors = reactive<Record<FieldName, string>>({ email: '', password: '' })
const accepted = ref(false)
function validate(): boolean {
errors.email = ''
errors.password = ''
const result = schema.safeParse({ email: form.email, password: form.password })
if (result.success) return true
for (const issue of result.error.issues) {
const name = issue.path[0] as FieldName
if (!errors[name]) errors[name] = issue.message
}
return false
}
function blur(name: FieldName) {
touched[name] = true
validate()
}
function submit() {
touched.email = true
touched.password = true
accepted.value = validate()
}
</script>
<template>
<form novalidate @submit.prevent="submit">
<label for="email">Email</label>
<input id="email" v-model="form.email" type="email" required @blur="blur('email')" />
<p v-if="touched.email && errors.email">{{ errors.email }}</p>
<label for="password">Password</label>
<input id="password" v-model="form.password" type="password" required @blur="blur('password')" />
<p v-if="touched.password && errors.password">{{ errors.password }}</p>
<button type="submit">Create account</button>
<p v-if="accepted">Details accepted.</p>
</form>
</template>
<style scoped>
form p {
margin: 0.25rem 0 0.75rem;
color: #b00020;
}
</style>When to use it
- Show a format error on an email field only after the user leaves it.
- Reject a form on submit and point at every field that needs attention.
- Share one schema between the form and the code that sends the values.
- Keep required and type attributes for assistive technology while controlling the messages yourself.
More examples
The same schema in Valibot, as its own module
Valibot builds a schema from small functions piped together, which keeps the shipped bundle smaller than Zod's method chaining. The validation lives in a plain module with no Vue in it, so the component only has to decide when to call it and where to put the strings. A module like this is also the easiest thing in a form to unit test.
// src/forms/signupSchema.ts
import * as v from 'valibot'
export const SignupSchema = v.object({
email: v.pipe(
v.string(),
v.nonEmpty('Enter your email'),
v.email('Enter a valid email address')
),
password: v.pipe(v.string(), v.minLength(8, 'Use at least 8 characters'))
})
export function validateSignup(input: unknown): Record<string, string> {
const result = v.safeParse(SignupSchema, input)
if (result.success) return {}
const flat = v.flatten<typeof SignupSchema>(result.issues)
const errors: Record<string, string> = {}
for (const [name, messages] of Object.entries(flat.nested ?? {})) {
if (messages?.[0]) errors[name] = messages[0]
}
return errors
}
console.log(validateSignup({ email: '', password: 'abc' }).email)
// Output: Enter your emailLetting the browser write the message
For required fields and simple formats you may not need a schema at all. `novalidate` suppresses the browser's own popup bubble, while `checkValidity()` and `validationMessage` keep working, so you get a localised message in your own markup. Reach for a schema once rules involve more than one field or have to match what the server enforces.
<!-- src/components/EmailField.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const email = ref('')
const message = ref('')
function check(event: FocusEvent) {
const input = event.target as HTMLInputElement
message.value = input.checkValidity() ? '' : input.validationMessage
}
</script>
<template>
<form novalidate @submit.prevent>
<label for="email">Email</label>
<input id="email" v-model="email" type="email" required @blur="check" />
<p v-if="message">{{ message }}</p>
</form>
</template>
<style scoped>
p {
color: #b00020;
}
</style>A cross-field rule as a computed
"These two fields must agree" reads better as a computed than as a schema rule, because it has nothing to reset: the message disappears the moment the values match. It also updates live, which is the one case where validating on each keystroke is helpful rather than nagging.
<!-- src/components/PasswordPair.vue -->
<script setup lang="ts">
import { computed, reactive } from 'vue'
const form = reactive({ password: '', confirm: '' })
const mismatch = computed(
() => form.confirm !== '' && form.confirm !== form.password
)
</script>
<template>
<div>
<label for="password">Password</label>
<input id="password" v-model="form.password" type="password" autocomplete="new-password" />
<label for="confirm">Confirm password</label>
<input
id="confirm"
v-model="form.confirm"
type="password"
autocomplete="new-password"
:aria-invalid="mismatch ? 'true' : undefined"
/>
<p v-if="mismatch">Both passwords must match</p>
</div>
</template>
<style scoped>
p {
color: #b00020;
}
</style>
Discussion