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
errorFor()is a method, not stored UI state — the errors live in oneerrorsobject, and each field reads its message on render.errorsis replaced (this.state.errors = {}/= body.errors), never mutated in place, so the shallow proxy re-renders.data-class="invalid:errorFor('name')"adds theinvalidclass only while a message exists — styling follows the data with no extra flag.- Client-side checks (required, email shape) can run first to save a round trip, but the server stays authoritative — never trust the client alone.