Vue cookbook · Intermediate
Validate forms with zod in a composable
Form validation logic is scattered across components with inconsistent messages.
Approach
Keep the form state in a reactive object, validate it with a zod schema, show errors only for touched fields or after submit, and reuse the same schema on the server.
Solution
// composables/useForm.ts
import { computed, reactive, ref } from "vue";
import type { ZodType } from "zod";
export function useForm<T extends Record<string, unknown>>(schema: ZodType<T>, initial: T) {
const values = reactive({ ...initial }) as T;
const touched = reactive<Record<string, boolean>>({});
const submitted = ref(false);
const result = computed(() => schema.safeParse(values));
const errors = computed<Record<string, string>>(() => {
if (result.value.success) return {};
const out: Record<string, string> = {};
for (const issue of result.value.error.issues) out[String(issue.path[0])] ??= issue.message;
return out;
});
const visibleError = (field: keyof T & string) =>
submitted.value || touched[field] ? errors.value[field] : undefined;
async function handleSubmit(onValid: (data: T) => Promise<void>) {
submitted.value = true;
if (result.value.success) await onValid(result.value.data);
}
return { values, touched, errors, visibleError, handleSubmit };
}
<script setup lang="ts">
import { z } from "zod";
import { useForm } from "./composables/useForm";
const Signup = z.object({
email: z.email("Enter a valid email"),
password: z.string().min(10, "Use at least 10 characters"),
});
const { values, touched, visibleError, handleSubmit } = useForm(Signup, { email: "", password: "" });
async function save(data: z.infer<typeof Signup>) {
await fetch("/api/signup", { method: "POST", body: JSON.stringify(data), headers: { "Content-Type": "application/json" } });
}
</script>
<template>
<form novalidate @submit.prevent="handleSubmit(save)">
<input v-model="values.email" type="email" @blur="touched.email = true" />
<p v-if="visibleError('email')" role="alert">{{ visibleError("email") }}</p>
<input v-model="values.password" type="password" @blur="touched.password = true" />
<p v-if="visibleError('password')" role="alert">{{ visibleError("password") }}</p>
<button>Create account</button>
</form>
</template>
Pitfalls
- Showing every error as soon as the form loads.
- Validating only on the client; the server must validate the same rules.
- Losing server-side errors (email taken) because only client validation is displayed.
Variations
- VeeValidate with
@vee-validate/zod, or FormKit, provide full-featured form handling. - Share the schema between a Nuxt server route and the page with a common
shared/module.
Further reading: Zod
Reference: https://zod.dev/
More Vue recipes
- Access child component methods with template refs and defineExpose
- Add SEO metadata and structured data in Nuxt
- Build an accessible modal with Teleport
- Build custom inputs with defineModel and modifiers
- Catch component errors with an error boundary
- Debounce user input with a custom ref
- Fetch data in a composable and cancel stale requests
- Fetch, refresh and share data with Nuxt useFetch