Skip to content

Vue useForm Composable

A composable for form state management with a three-phase lifecycle: data() loads initial values, validate() checks them, and submit() handles submission. Provides automatic re-validation on data changes via a reactive watcher, touch tracking for conditional error display, and a reset() method that reloads data.

When to use

Use this composable when a form needs validation before submission with automatic re-validation as the user types. It manages the full lifecycle — loading initial data, validating on every change, gating submission on validity, and resetting back to a clean state.

The pattern

Create a form with data(), validate(), and submit() functions. The form model auto-validates on data changes and tracks whether the user has attempted submission.

Create form

Wire the form model to a template with v-model bindings and gate error display on form.touched.

vue
<script setup>
  import { useForm } from "./use-form.js";
  
  const form = useForm({
    data() {
      return { name: "", email: "" };
    },
    validate(input) {
      const errors = [];
      if (!input.name) {
        errors.push({ message: "Name is required" });
      }
      if (!input.email) {
        errors.push({ message: "Email is required" });
      }
      return errors;
    },
    async submit(input) {
      await api.createUser(input);
      emit("success");
    },
  });
</script>

<template>
  <form @submit.prevent="form.submit()">
    <ValidationErrors
      :errors="form.errors"
      :show="form.touched"
    />
    <Input label="Name" v-model="form.data.name" />
    <Input label="Email" v-model="form.data.email" />
    <button type="submit">Save</button>
  </form>
</template>

Edit form with async data

The data() function can be async, supporting edit forms that fetch existing data from an API. The reset() method calls data() again to reload from the source.

javascript
const form = useForm({
  async data() {
    const user = await api.getUser(props.userId);
    return { name: user.name, email: user.email };
  },
  validate(input) {
    return UserSchema.validate(input).errors;
  },
  async submit(input) {
    await api.updateUser(props.userId, input);
    emit("saved");
  },
});

Implementing the FormModel

A FormModel class manages reactive data, errors, and touched state. A Watcher (see reactivity/watcher.md) auto-validates whenever data changes. The useForm composable instantiates the model.

javascript
import { isProxy, reactive } from "vue";
import { Watcher } from "./watcher.js";

class FormModel {
  static get defaults() {
    return {
      data() { return {}; },
      validate() {},
      submit() {},
    };
  }
  
  data = {};
  touched = false;
  errors = null;
  
  constructor(options) {
    const proxy = reactive(this);
    proxy.init(options);
    return proxy;
  }
  
  async init(options) {
    if (!isProxy(this)) {
      throw new Error("`this` must be a reactive proxy");
    }
    this.options = {
      ...FormModel.defaults,
      ...options,
    };
    this.touched = false;
    this.errors = null;
    this.data = await this.options.data();
    this.watcher ??= new Watcher(() => this.data, () => this.validate());
  }
  
  validate() {
    this.errors = this.options.validate(this.data);
    return isEmpty(this.errors);
  }
  
  async submit() {
    this.touched = true;
    if (!this.validate()) return;
    try {
      await this.options.submit(this.data);
    } catch (error) {
      this.errors ??= [];
      this.errors.push(error);
    }
  }
  
  async reset() {
    this.touched = false;
    this.errors = null;
    this.data = await this.options.data();
  }
}

export function useForm(options) {
  return new FormModel(options);
}

Three-phase lifecycle

The data() function provides initial values (sync or async), validate() returns an errors array, and submit() runs only after validation passes. This separation keeps each concern testable and composable.

Auto-validation with Watcher

The Watcher deeply watches this.data and calls validate() on every change. Errors update reactively as the user types, without explicit event wiring. The ??= operator creates the watcher only on first init — on reset(), the existing watcher continues watching the new data object.

Touch tracking

The touched property starts false and flips to true on first submit(). Templates can gate error display on form.touched so errors don't appear before the user has tried to submit.

Trade-offs

  • Coupled to Watcher class — The auto-validation watcher is built in. Forms that don't need live validation still pay the watcher cost. A watch: false option could disable it, but this implementation doesn't provide one.
  • Error shape assumption — The validate() function should return an array of { message } objects (or falsy for valid). This must match the ValidationErrors component's expectations.
  • No field-level errors — Errors are a flat list with no field association. Displaying errors next to specific fields requires the caller to filter the errors array by field name.
  • Async init timing — Because data() can be async, form.data is {} until the promise resolves. The template should handle this empty state or show a loading indicator.