Forms & validation
The honest pattern needs no form library: a declared controller owns field signals and submit logic, a schema library (zod shown here) validates the aggregate, and the view just binds inputs.
Schema + controller
import { z } from 'zod';
const schema = z.object({
orgName: z.string().min(2, 'Organization name is too short'),
email: z.string().email('Enter a valid email'),
region: z.enum(['us-east', 'eu-west', 'ap-south']),
notifyDeploys: z.boolean(),
});
type SettingsForm = z.infer<typeof schema>;
@Controller()
export class SettingsController {
orgName = signal('Acme Inc');
email = signal('ops@acme.dev');
region = signal<SettingsForm['region']>('us-east');
notifyDeploys = signal(true);
errors = signal<Partial<Record<keyof SettingsForm, string>>>({});
saved = signal(false);
dirty = signal(false);
touch() { this.dirty.value = true; this.saved.value = false; }
submit() {
const parsed = schema.safeParse({
orgName: this.orgName.value,
email: this.email.value,
region: this.region.value,
notifyDeploys: this.notifyDeploys.value,
});
if (!parsed.success) {
const errs: typeof this.errors.value = {};
for (const i of parsed.error.issues)
errs[i.path[0] as keyof SettingsForm] = i.message;
this.errors.value = errs;
return;
}
this.errors.value = {};
this.saved.value = true;
this.dirty.value = false;
}
}The view binds signals
@Component({ controllers: [SettingsController] })
export class SettingsView extends View {
private c = inject(SettingsController);
render() {
const c = this.c;
return (
<form onSubmit={(e) => { e.preventDefault(); c.submit(); }}>
<Label for="email">Contact email</Label>
<Input
id="email" type="email"
value={c.email.value}
class={c.errors.value.email ? 'border-destructive' : ''}
onInput={(e) => {
c.touch();
c.email.value = e.currentTarget.value;
}}
/>
{c.errors.value.email && (
<p class="text-destructive">{c.errors.value.email}</p>
)}
<Button type="submit">Save changes</Button>
{c.saved.value && <Badge variant="success">Saved</Badge>}
{c.dirty.value && !c.saved.value && <span>unsaved changes</span>}
</form>
);
}
}Why this shape
- Field state lives off the render path. Typing writes a signal — no synthetic form context, no controlled-input re-render cascades (the view re-renders, but inputs keep DOM focus naturally).
- Validation is a plain function call.
schema.safeParseat submit; per-field errors land in a record the view renders inline. Swap zod for valibot/arktype — nothing else changes. - The controller is testable headless.
new SettingsController()+submit()in a unit test exercises the whole validation path without DOM. - Sharing is a declaration. Declared on
@Component, field sub-components inject the same controller — multi-field widgets for free. See Controllers.
Full working version in the enterprise demo → Settings tab.