iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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…