Vue cookbook · Intermediate

Validate forms with zod in a composable

Form validation logic is scattered across components with inconsistent messages.

Create a free account to save this page, take notes and track your progress. Sign in

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

Was this page helpful?