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>oraria-label. Placeholder text is not a label. - Errors need
aria-describedbypointing at the message, andaria-invalidon 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-modelwork on a custom component?” - it bindsmodelValueand listens forupdate:modelValue. Since 3.4,defineModel()generates both and returns a writable ref, which is the current way to write it. - “Why is
v-model.numberneeded on a number input?” - the DOM gives you a string regardless oftype="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
computedfrom 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.