Skip to the solution
MediumFrontend9 min read

How do you handle forms and validation in Vue?

703 views

The problem

Question presented to candidate: "Build the sign-up form for our app: email, age, password and a terms checkbox. Errors should not shout at users before they have typed anything, the button must not double-submit, and the server can still reject an email that is taken. How do you handle forms and validation in Vue?"

What a strong answer should cover:

  • v-model on inputs (with .trim, .number, .lazy), one reactive form object, and @submit.prevent on a real <form>.
  • Validation as derived state: an errors computed from the current values, never a second copy that can drift; isValid computed from it.
  • When to show errors: after blur (touched) or after the first submit attempt, not on the first keystroke.
  • Submit handling: block when invalid, a submitting flag to disable the button, and mapping server-side errors back to the field or a form message.
  • Edge cases: .number gives "" when the field is cleared, the browser's own validation (novalidate), and accessibility (aria-invalid, error text linked to the input).
  • When a library pays off: VeeValidate (or FormKit) for many forms, schema validation, field arrays and consistent touched/dirty tracking.

Clarifying questions expected:

  • "Is the same validation needed on the server, and can we share a schema?"
  • "How many forms like this will the app have?"

Code / implementation expected: Yes β€” a small form with computed errors, touched state and an async submit.

  • vue
  • forms
  • validation

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

9 min read Β· 9 sections

Read the code

VueSign-up form: computed errors, touched state, async submit with a fake server
<script setup>
import { reactive, computed, ref } from "vue";

const form = reactive({ email: "", age: null, password: "", terms: false });
const touched = reactive({});      // which fields the user has left (blur)
const submitted = ref(false);      // after the first submit, show every error
const submitting = ref(false);
const serverError = ref("");
const result = ref("");

// Validation rules are plain functions: return an error message or "".
const rules = {
  email: (v) => (!v ? "Email is required" : /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? "" : "Enter a valid email"),
  age: (v) => (v === null || v === "" ? "Age is required" : v < 18 ? "You must be 18 or older" : ""),
  password: (v) => (v.length < 8 ? "At least 8 characters" : ""),
  terms: (v) => (v ? "" : "Please accept the terms"),
};

// Derived, never stored: errors always match the current values.
const errors = computed(() =>
  Object.fromEntries(Object.keys(rules).map((k) => [k, rules[k](form[k])])),
);
const isValid = computed(() => Object.values(errors.value).every((e) => !e));
const show = (field) => (touched[field] || submitted.value) && errors.value[field];

// Fake server call (no network): rejects a taken email like a real API might.
const fakeRegister = (data) =>
  new Promise((resolve, reject) =>
    setTimeout(() => (data.email === "taken@example.com" ? reject({ field: "email", message: "Email already registered" }) : resolve({ id: 42 })), 200),
  );

async function onSubmit() {
  submitted.value = true;
  serverError.value = "";
  if (!isValid.value) return;
  submitting.value = true;
  try {
    const { id } = await fakeRegister({ ...form });
    result.value = `Registered user ${id}`;
  } catch (e) {
    serverError.value = e.message;
  } finally {
    submitting.value = false;
  }
}
</script>

<template>
  <form @submit.prevent="onSubmit" novalidate>
    <label>Email <input v-model.trim="form.email" type="email" @blur="touched.email = true" /></label>
    <p v-if="show('email')" class="err">{{ errors.email }}</p>

    <label>Age <input v-model.number="form.age" type="number" @blur="touched.age = true" /></label>
    <p v-if="show('age')" class="err">{{ errors.age }}</p>

    <label>Password <input v-model="form.password" type="password" @blur="touched.password = true" /></label>
    <p v-if="show('password')" class="err">{{ errors.password }}</p>

    <label><input v-model="form.terms" type="checkbox" /> I accept the terms</label>
    <p v-if="show('terms')" class="err">{{ errors.terms }}</p>

    <button type="submit" :disabled="submitting">{{ submitting ? "Saving..." : "Create account" }}</button>
    <p v-if="serverError" class="err">Server: {{ serverError }}</p>
    <p v-if="result">{{ result }}</p>
  </form>
</template>
VueThe same idea with VeeValidate 4.15 (useForm + defineField)
<script setup>
// VeeValidate 4.15: the library tracks values, errors, touched and submit state.
// Rules here are plain functions (return true or an error message).
import { useForm } from "vee-validate";

const { defineField, errors, handleSubmit, meta, isSubmitting } = useForm({
  initialValues: { email: "", password: "" },
  validationSchema: {
    email: (v) => (!!v && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) || "Enter a valid email",
    password: (v) => (v?.length ?? 0) >= 8 || "At least 8 characters",
  },
});

const [email, emailAttrs] = defineField("email");
const [password, passwordAttrs] = defineField("password");

const onSubmit = handleSubmit(
  async (values) => console.log("submit:", JSON.stringify(values)), // only runs when valid
  ({ errors }) => console.log("invalid:", JSON.stringify(errors)),
);
</script>

<template>
  <form @submit="onSubmit" novalidate>
    <input v-model="email" v-bind="emailAttrs" type="email" />
    <span class="err">{{ errors.email }}</span>
    <input v-model="password" v-bind="passwordAttrs" type="password" />
    <span class="err">{{ errors.password }}</span>
    <button :disabled="isSubmitting">Sign up</button>
    <p>valid: {{ meta.valid }}, touched: {{ meta.touched }}</p>
  </form>
</template>

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 40 of 92 in Vue.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Vue.js questions
Up nextMedium

How do you use TypeScript with Vue (typed props, emits, refs, vue-tsc)?

Continue