Recipe: Form validation

A signup form that posts to the server and renders per-field errors from the response. The server is the source of truth: this.fetch() throws on a non-2xx status, you read the 422 body for { errors: { field: message } }, and a status field drives the submit button and the success / error banners.

Markup

<form data-component="signup-form" @submit.prevent="submit">
  <div class="field-group">
    <label>Full name</label>
    <input data-model="name" data-class="invalid:errorFor('name')" />
    <span data-if="errorFor('name')" data-text="errorFor('name')"></span>
  </div>

  <div class="field-group">
    <label>Email</label>
    <input data-model="email" data-class="invalid:errorFor('email')" />
    <span data-if="errorFor('email')" data-text="errorFor('email')"></span>
  </div>

  <div class="field-group">
    <label>Password</label>
    <input type="password" data-model="password" data-class="invalid:errorFor('password')" />
    <span data-if="errorFor('password')" data-text="errorFor('password')"></span>
  </div>

  <div data-if="status === 'success'">Account created.</div>
  <div data-if="status === 'error'">Network error. Try again.</div>

  <button
    type="submit"
    data-bind="disabled:status === 'submitting'"
    data-text="status === 'submitting' ? 'Creating…' : 'Create account'"
  ></button>
</form>

Component

Micra.define("signup-form", {
  state: {
    name: "",
    email: "",
    password: "",
    errors: {}, // { fieldName: message }
    status: "idle", // 'idle' | 'submitting' | 'success' | 'error'
  },

  // Derived: read one field's error for the markup.
  errorFor(field) {
    return this.state.errors[field] || "";
  },

  async submit() {
    this.state.status = "submitting";
    this.state.errors = {}; // replace, never mutate

    try {
      await this.fetch("/api/signup", {
        method: "POST",
        body: {
          name: this.state.name,
          email: this.state.email,
          password: this.state.password,
        },
      });
      this.state.status = "success";
    } catch (e) {
      // FetchError carries e.status and e.response (the raw Response).
      // A 422 with a JSON body gives the per-field errors.
      if (e.status === 422) {
        const body = await e.response.json().catch(() => ({}));
        this.state.errors = body.errors || {};
        this.state.status = "idle";
      } else {
        this.state.status = "error"; // network / 500 — generic banner
      }
    }
  },
});

Server contract

The recipe expects a 422 with this shape:

{
  "errors": {
    "email": "is already taken",
    "password": "must be at least 8 characters"
  }
}

this.fetch() throws a FetchError on any non-2xx response, carrying e.status (the HTTP code) and e.response (the raw Response). Parse the body yourself with await e.response.json() — that keeps Micra’s error type small and leaves the parsing strictness to you.

Notes