After the basics of Signal Forms, here are the patterns you use in real forms: dynamic lists, async validation, conditional fields, reusable schemas and cross-field validation. They are all expressed in the schema, not in the template — the template stays simple.
Dynamic lists: applyEach
interface Invoice {
client: string;
lines: { description: string; qty: number; price: number }[];
}
readonly model = signal<Invoice>({ client: '', lines: [{ description: '', qty: 1, price: 0 }] });
readonly f = form(this.model, (p) => {
required(p.client);
applyEach(p.lines, (line) => { // the same rules for every element
required(line.description);
min(line.qty, 1);
});
});
// You add / remove elements by changing the MODEL — the field tree updates by itself
addLine() {
this.model.update((m) => ({ ...m, lines: [...m.lines, { description: '', qty: 1, price: 0 }] }));
}| Description | Qty | Price | Total | |
|---|---|---|---|---|
| 3.500 RON |
Invoice total: 3.500 RONvalid
Async validation: validateHttp / validateAsync
import { validateHttp, validateAsync } from '@angular/forms/signals';
form(this.model, (p) => {
required(p.username);
minLength(p.username, 3);
// validateHttp: an HTTP request (through HttpClient → goes through the interceptors)
validateHttp(p.username, {
request: ({ value }) => `/api/username-available?username=${value()}`,
debounce: 400, // v22
onSuccess: (res: { available: boolean }) =>
res.available ? undefined : { kind: 'taken', message: 'Username taken' },
onError: () => ({ kind: 'network', message: 'Could not verify' }),
});
// validateAsync: any resource (e.g. an SDK, IndexedDB)
validateAsync(p.iban, {
params: ({ value }) => value(),
factory: (iban) => resource({ params: iban, loader: ({ params }) => this.bank.check(params) }),
onSuccess: (ok) => (ok ? undefined : { kind: 'iban', message: 'Invalid IBAN' }),
onError: () => undefined,
});
});
// In the template: f.username().pending() is true while the check runs
// v22: f.username().reloadValidation() — re-runs the async validation manually pending: false · valid: false
Conditional fields: hidden, disabled, readonly
form(this.model, (p) => {
// hidden: the field is not displayed AND not validated
hidden(p.company, ({ valueOf }) => valueOf(p.type) !== 'company');
required(p.company);
// disabled: displayed, not editable, not validated (with an optional reason)
disabled(p.coupon, ({ valueOf }) => !valueOf(p.hasCoupon));
disabled(p.email, () => this.user.isLocked() && 'Account is locked'); // a string = the reason
// readonly: displayed, not editable, but its value is sent
readonly(p.id);
// conditional required
required(p.phone, { when: ({ valueOf }) => valueOf(p.contactBy) === 'phone' });
});
// template: @if (!f.company().hidden()) { <input [formField]="f.company" /> }Reusable schemas: schema, apply, applyWhen
// A reusable schema — you define it once and apply it anywhere
export const addressSchema = schema<Address>((a) => {
required(a.street);
required(a.city);
pattern(a.zip, /^\d{6}$/, { message: 'Postal code: 6 digits' });
});
form(this.model, (p) => {
apply(p.billing, addressSchema); // always
applyWhen(p.shipping, ({ valueOf }) => !valueOf(p.sameAsBilling), addressSchema); // conditional
hidden(p.shipping, ({ valueOf }) => valueOf(p.sameAsBilling));
});Cross-field validation
// Validation on a field that depends on another
validate(p.endDate, ({ value, valueOf }) =>
value() < valueOf(p.startDate) ? { kind: 'range', message: 'End date is before the start' } : undefined,
);
// validateTree: a group-level rule that can report errors on several fields
validateTree(p, ({ value, fieldTreeOf }) => {
const { min, max } = value();
return min > max
? [
{ kind: 'range', message: 'The minimum is greater', fieldTree: fieldTreeOf(p.min) },
{ kind: 'range', message: 'The maximum is smaller', fieldTree: fieldTreeOf(p.max) },
]
: undefined;
});Zod / Valibot schemas
// Already have Zod / Valibot / ArkType schemas? Use them directly (Standard Schema)
import { validateStandardSchema } from '@angular/forms/signals';
import { z } from 'zod';
const UserSchema = z.object({ name: z.string().min(2), email: z.email() });
form(this.model, (p) => {
validateStandardSchema(p, UserSchema); // the errors land on the matching fields
});Common pitfalls
- Logic in the template (
@if (model().type === 'company')around fields) withouthidden()in the schema → the hidden field is still validated and the form stays invalid. Put the rule in the schema and readhidden(). - Async validation without debounce → a request on every keystroke.
- Lists changed by mutation (
model().lines.push()) → the tree doesn’t update. Usemodel.updatewith a new array. - Errors on groups: an error on
p(the root) doesn’t appear next to an input. Target the field withfieldTree(or displayerrorSummary()).