frontend / vue / 21_forms_and_validation.md

Forms and validation

5 interview angles 3 min read source

Forms and validation

Vue’s v-model handles binding; it does nothing about validation, dirty state, submission or errors. Those are the parts a real form needs, and the parts interviewers ask about.

What v-model actually does

<input v-model="name" />
<!-- expands to -->
<input :value="name" @input="name = $event.target.value" />

Different elements expand differently: checkboxes bind checked and change, selects bind value and change, and a custom component binds modelValue and update:modelValue (or whatever defineModel declares). Modifiers matter here — .number because <input type="number"> still yields a string, .trim because trailing whitespace passes most validators, .lazy to sync on change rather than every keystroke. See 18_template_syntax_and_directives.md.

Rolling your own

Fine for two or three fields:

const form = reactive({ email: '', password: '' });
const touched = reactive({ email: false, password: false });
const errors = computed(() => ({
  email: !form.email.includes('@') ? 'Invalid email' : null,
  password: form.password.length < 8 ? 'Too short' : null,
}));
const isValid = computed(() => Object.values(errors.value).every((e) => !e));

Deriving errors in a computed rather than storing them in state is the right instinct — errors are a function of the values, and storing them creates two sources of truth that drift. Showing an error only once the field is touched (or after a submit attempt) is what makes the form feel reasonable rather than hostile.

What you will end up reimplementing beyond this: field arrays, async validation with debounce and race handling, submit state, server-side error mapping, and nested objects. That is when a library pays.

VeeValidate

The mainstream choice. Two styles: components (<Form>, <Field>, <ErrorMessage>) or composables (useForm, useField). The composable form composes better with your own markup.

import { useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import * as z from 'zod';

const { handleSubmit, errors, defineField } = useForm({
  validationSchema: toTypedSchema(
    z.object({ email: z.string().email(), age: z.number().min(18) })
  ),
});
const [email, emailAttrs] = defineField('email');
const onSubmit = handleSubmit(async (values) => { await api.save(values); });

The schema adapter is the part that matters: one Zod schema types the form values and validates them, and the same schema can validate on the server. That single-source-of-truth argument is the reason to prefer schema validation over per-field rules.

FormKit

The other serious option. It generates inputs, labels, error markup and accessibility attributes from a schema, so a large form is configuration rather than markup. Good fit for admin panels and anything data-driven; less good when the design is bespoke, because you are then fighting its markup.

Nuxt and server-side validation

In Nuxt, validate on both sides with the same schema:

// server/api/user.post.ts
export default defineEventHandler(async (event) => {
  const body = await readValidatedBody(event, schema.parse);
});

Client validation is UX; server validation is correctness. Saying only “I validate with Zod on the client” invites the obvious follow-up about anyone posting directly to the endpoint. See 11_nuxt.md.

Accessibility

The part most candidates skip and interviewers notice:

  • Every input needs a <label for> or aria-label. Placeholder text is not a label.
  • Errors need aria-describedby pointing at the message, and aria-invalid on the field.
  • The error summary should be focusable and announced on submit failure, or a screen-reader user does not learn the form failed.
  • Do not disable the submit button while invalid — it gives no explanation. Let submission fail and show why.

See ../16_accessibility/.

Interview angle

  • “How does v-model work on a custom component?” - it binds modelValue and listens for update:modelValue. Since 3.4, defineModel() generates both and returns a writable ref, which is the current way to write it.
  • “Why is v-model.number needed on a number input?” - the DOM gives you a string regardless of type="number", so a comparison or arithmetic downstream silently misbehaves without the coercion.
  • “Where do you put validation logic?” - one schema (Zod or similar) shared between client and server. Client validation is for feedback, server validation is for correctness; you need both, and one schema means they cannot disagree.
  • “Should errors live in state?” - no, derive them with computed from the values. Storing errors separately gives you two sources of truth that go stale. Track touched in state, since that is genuinely independent.
  • “How do you keep a large form fast?” - keep field state local to the field component so a keystroke re-renders one input rather than the whole form, and debounce async validation rather than the input binding itself.