Modern Angular: Signals, Standalone Components and SSR · Components, Signals and Data
Signal Forms: Models, Validation and Submission
Build forms with Angular Signal Forms: a signal model, schema validation rules, field state in templates, cross-field validation and submission with server errors.
What you'll build
A sign-up form built with Signal Forms: the form's data is a signal model, a schema adds required, email, minLength and a cross-field "passwords match" rule, the template binds inputs with [formField], and submission maps a server error ("email already registered") back onto the email field.
The idea
form(modelSignal, schemaFn)creates a field tree mirroring the model; each field (f.email) exposes state such asvalue(),errors(),valid(),touched().- Schema rules:
required,email,min,minLength,pattern, andvalidate(path, ctx => error | undefined)for custom and cross-field rules (ctx.valueOf(otherPath)). [formField]="f.email"binds an input to a field both ways.submit(form, action)marks fields touched, runs the action only if valid, and applies errors the action returns to the matching fields.- Signal Forms are stable in Angular 22; Reactive Forms (
FormBuilder) remain supported for existing code.
The code
import { Component, signal } from "@angular/core";
import { FormField, email, form, minLength, required, submit, validate } from "@angular/forms/signals";
import { TestBed } from "@angular/core/testing";
import { BrowserTestingModule, platformBrowserTesting } from "@angular/platform-browser/testing";
type SignUp = { email: string; password: string; confirm: string };
@Component({
selector: "app-signup",
imports: [FormField],
template: `
<form (submit)="save($event)">
<input id="email" [formField]="f.email" />
<input id="password" type="password" [formField]="f.password" />
<input id="confirm" type="password" [formField]="f.confirm" />
@for (field of [f.email, f.password, f.confirm]; track $index) {
@for (error of field().errors(); track error.kind) { <p class="error">{{ error.message }}</p> }
}
<button type="submit">Create account</button>
<p class="status">{{ status() }}</p>
</form>
`,
})
class SignUpForm {
model = signal<SignUp>({ email: "", password: "", confirm: "" });
f = form(this.model, (path) => {
required(path.email, { message: "Email is required" });
email(path.email, { message: "Enter a valid email" });
minLength(path.password, 10, { message: "Password needs at least 10 characters" });
validate(path.confirm, ({ value, valueOf }) =>
value() === valueOf(path.password) ? undefined : { kind: "mismatch", message: "Passwords don't match" });
});
status = signal("");
async save(event: Event) {
event.preventDefault();
const ok = await submit(this.f, async (form) => {
// A real app calls its API here; this one pretends the email is taken.
if (form.email().value() === "[email protected]") {
return [{ fieldTree: form.email, kind: "server", message: "Email already registered" }];
}
return undefined;
});
this.status.set(ok ? `Account created for ${this.model().email}` : "");
}
}
async function main() {
TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
const fixture = TestBed.createComponent(SignUpForm);
const el: HTMLElement = fixture.nativeElement;
const type = (id: string, value: string) => {
const input = el.querySelector<HTMLInputElement>(`#${id}`)!;
input.value = value;
input.dispatchEvent(new Event("input"));
};
const submitForm = async (label: string) => {
el.querySelector("button")!.click();
await fixture.whenStable();
await fixture.whenStable();
const messages = [...el.querySelectorAll(".error, .status")].map((n) => n.textContent!.trim()).filter(Boolean);
console.log(`${label}: ${messages.join(" / ")}`);
};
await submitForm("empty");
type("email", "ana@"); type("password", "short"); type("confirm", "other");
await submitForm("invalid");
type("email", "[email protected]"); type("password", "correct-horse"); type("confirm", "correct-horse");
await submitForm("taken email");
type("email", "[email protected]");
await submitForm("valid");
}
main();
Output
empty: Email is required invalid: Enter a valid email / Password needs at least 10 characters / Passwords don't match taken email: Email already registered valid: Account created for [email protected]
Run this on your computer — see “Get Started” for setup.
Common mistakes
- Keeping a separate copy of the form data and syncing it manually; the model signal is the data.
- Cross-field rules implemented in the submit handler instead of
validatewithvalueOf, so errors don't show on the field. - Showing server errors in a generic banner instead of returning them from
submitonto the right field. - Trusting client-side validation; the server must validate again.
Key points
- form(model, schema) creates a typed field tree from a signal.
- Built-in rules plus validate() for custom and cross-field validation.
- submit() validates, runs the action and maps server errors to fields.
Further reading: Signal Forms overview · Validation · Form submission
Practice questions
What does form(model, schema) return?
Answer
A field tree mirroring the model's shape; each field exposes reactive state such as value, errors, valid and touched, and binds to inputs with [formField].
How do you validate one field against another?
Answer
With validate(path, ctx => ...), reading the other field's value with ctx.valueOf(otherPath) and returning an error or undefined.
How are server-side errors shown on the right field?
Answer
The submit action returns errors with a fieldTree pointing at the field; submit applies them as validation errors on that field.
Open in the app to mark lessons complete, take notes and track your progress.