Recipe: Form with server validation

Sign-up form with three fields, per-field error messages from a server 422 response, and a four-state submit button (idlesubmittingsuccess / error). No validation library — server is the source of truth, client just renders what comes back.

submit state 422 errors no library

Live preview

Full source

<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>
Micra.define('signup-form', {
  state: {
    name: '',
    email: '',
    password: '',
    errors: {},                  // { fieldName: message }
    status: 'idle',              // 'idle' | 'submitting' | 'success' | 'error'
  },

  // ── derived ──────────────────────────────────────────────
  errorFor(field) {
    return this.state.errors[field] || ''
  },

  // ── actions ──────────────────────────────────────────────
  async submit() {
    this.state.status = 'submitting'
    this.state.errors = {}                  // replace, not 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).
      // For a 422 with a JSON body, parse it to get 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 {
        // Network / 500 / anything else — generic banner.
        this.state.status = 'error'
      }
    }
  },
})

Server contract

The recipe assumes a JSON 422 with this shape:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
  "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 status code) and e.response (the raw Response). Parse the JSON body yourself with await e.response.json() — that keeps Micra's error type small and lets you control how strict the parsing is.

Why this code (and not the obvious alternative)

What Anti-pattern Idiomatic Micra
Per-field error display boolean flags emailInvalid + emailError kept in sync single errors object, errorFor(field) method
Submit button label btnLabel state field updated in submit/finally data-text="status === 'submitting' ? … : …"
Disable while submitting btnDisabled state field data-bind="disabled:status === 'submitting'"
Client validation library zod / yup / joi + sync rules server validates, client renders — one source of truth
Reset errors on each submit this.state.errors.email = null (invisible to proxy) this.state.errors = {} — replace top-level

Pitfalls

  • Never mutate errors in place. this.state.errors.email = 'msg' is a nested write — the proxy doesn't see it. Always replace: this.state.errors = { ...this.state.errors, email: 'msg' } or full replace as in submit.
  • Don't add client validation that duplicates the server — the moment server rules drift (new password policy, blocked email domains), client and server disagree. If you want instant feedback, debounce a /api/check-email probe on blur, but the source of truth is still the server.
  • Don't reset status in finally — a 422 response is an "OK, here are the errors" state, not an error state. Leaving status === 'idle' after 422 lets the user retry naturally.
  • Don't bind @keydown.enter — let the form submit normally and use @submit.prevent. Enter inside a single-field input also submits, and the browser handles that for free.