In Angular Signal Forms, how do form(), a schema function and the [formField] directive work together in a registration form?
answer
- the model is a writable signal
- a field tree mirrors its shape
- rules bound to paths, once
- call a field to read its state
basics
~20 sThe data lives in a writable signal; form(model, schema) returns a FieldTree mirroring it; the schema runs once, binding rules like required() to paths; [formField] two-way binds an input to a field, whose state you read by calling it.
solid answer
~40 sIn Signal Forms (`@angular/forms/signals`, public API since v22) the **model** is a plain `signal({username: '', email: '', password: ''})` and it stays the single source of truth. `form(model, schemaFn)` returns a **`FieldTree`** with the same shape: `registrationForm.email` is a field, and calling it, `registrationForm.email()`, returns its `FieldState` with signals like `value()`, `errors()`, `valid()`, `touched()` and `dirty()`. The schema function runs **once** at creation and binds rules to paths (`required(path.email)`, `email(path.email)`, `minLength(path.password, 12)`); the rules then re-run reactively on every change. In the template, `<input [formField]="registrationForm.email" />` two-way binds the input to that field, so typing writes straight into the model signal. The component must import `FormField`.
code
ts · 46 linesimport {Component, signal} from '@angular/core';
import {email, form, FormField, minLength, required} from '@angular/forms/signals';
interface RegistrationModel {
username: string;
email: string;
password: string;
}
@Component({
selector: 'app-registration',
imports: [FormField],
template: `
<label>Username <input [formField]="registrationForm.username" /></label>
<label>Email <input type="email" [formField]="registrationForm.email" /></label>
@if (registrationForm.email().touched() && registrationForm.email().invalid()) {
@for (error of registrationForm.email().errors(); track $index) {
<p class="error">{{ error.message }}</p>
}
}
<label>Password <input type="password" [formField]="registrationForm.password" /></label>
<button type="button" [disabled]="registrationForm().invalid()" (click)="save()">
Register
</button>
`,
})
export class Registration {
// the model signal is the single source of truth
protected readonly model = signal<RegistrationModel>({username: '', email: '', password: ''});
// the schema function runs once, when the form is created
protected readonly registrationForm = form(this.model, (path) => {
required(path.username, {message: 'Choose a username'});
required(path.email, {message: 'Email is required'});
email(path.email, {message: 'Enter a valid email address'});
minLength(path.password, 12, {message: 'Use at least 12 characters'});
});
protected save(): void {
const payload = this.model(); // already holds what the user typed
// send payload to the server
}
}go deeper
Know the three pieces: a signal model, form() with a schema function of rules, and [formField] on each input. Be able to read a field's errors() and touched() in a template.
Explain that the model stays the source of truth, that the schema runs once while its rules stay reactive, and why form() needs an injection context.
Discuss model design constraints (plain objects, initial values for every field, '' for text) and why constraint attributes move from the template into the schema.
Weigh how a schema-centred forms layer changes where validation knowledge lives in a large codebase, and how to share schemas across features.
## The three pieces Signal Forms is the forms system in `@angular/forms/signals`. It shipped as experimental in Angular 21 and became **public API in v22.0**. It is built from three pieces that each do one job. | Piece | What it is | Job | |---|---|---| | **model** | a `WritableSignal` you create with `signal()` | holds the form data; the single source of truth | | **`form(model, schema?)`** | a function that returns a `FieldTree` | mirrors the model's shape and exposes per-field state | | **`[formField]`** | the `FormField` directive | binds one UI control to one field, both ways | ## The model The model is an ordinary writable signal holding a plain object, for example `signal<RegistrationModel>({username: '', email: '', password: ''})`. Signal Forms does **not** keep a private copy: when the user types, the new value is written into this signal, and when your code calls `model.set(...)` the inputs update. Reading `this.model()` at submit time gives you the whole payload. A few rules about its shape: - give every field an initial value; a key that is `undefined` does not become a field; - native text inputs cannot show `null`, so use `''` for empty strings; - keep the objects and arrays **plain**: class instances lose their prototype on the first write (parents are shallow-copied), and `Map` or `Set` produce empty field trees. ## form() and the field tree `form(model, schemaFn)` returns a **`FieldTree`**, an object that mirrors the model. It is both **navigable** and **callable**: - `registrationForm.email` is the field for `model().email`, typed from the model (`registrationForm.phone` is a compile error if the model has no `phone`); - `registrationForm.email()` returns that field's **`FieldState`**, a bundle of signals: `value()`, `errors()`, `valid()`, `invalid()`, `pending()`, `touched()`, `dirty()`, `disabled()`, `hidden()`, `readonly()` and more; - `registrationForm()` returns the state of the whole form, which aggregates its children. `form()` uses dependency injection internally, so it must run in an **injection context**, such as a field initializer or constructor, unless you pass an `injector` option. ## The schema function The optional second argument is a function that receives a path tree for the model. It runs **once**, when the form is created. Its job is to bind rules to paths: 1. validation rules: `required()`, `email()`, `min()`, `max()`, `minLength()`, `maxLength()`, `pattern()`, and the custom `validate()` and `validateAsync()`; 2. availability rules: `disabled()`, `hidden()`, `readonly()`; 3. timing rules: `debounce()`. The rules themselves are reactive: whenever a value they read changes, they re-evaluate and the field state signals update. Built-in rules accept a `message` option, so the error text lives next to the rule, and each error in `errors()` is an object with a `kind` such as `'required'` and an optional `message`. ## [formField] in the template The component imports `FormField` and writes `<input [formField]="registrationForm.email" />`. The directive: - writes the field's value into the input and the input's changes back into the model; - marks the field touched when the input blurs; - reflects state such as `required` and `disabled` onto the element. Because the directive owns those properties, the template type-checker rejects binding `[value]`, `[disabled]` or adding a `required` attribute on the same element; express them as schema rules instead. ## Common first-week mistakes - **Forgetting `imports: [FormField]`.** Without it the directive never matches, and the template type-checker rejects `[formField]` as an unknown property of the element. - **Treating the field as the value.** `registrationForm.email` is a field; `registrationForm.email().value()` is the value, and `this.model().email` is the same value read from the model. - **Rebuilding the form.** Calling `form()` inside a method or a getter creates a new tree each time; create it once, in a field initializer. - **Replacing the model object with a new shape.** The tree mirrors the model's keys; a key that later becomes `undefined` stops being a field. - **Showing errors immediately.** Gate messages on `touched()` (or a submit attempt), otherwise an empty form greets the user with red text. ## Reading state in the template A common pattern is to show errors only after interaction: ```html @if (registrationForm.email().touched() && registrationForm.email().invalid()) { @for (error of registrationForm.email().errors(); track $index) { <p>{{ error.message }}</p> } } ``` Everything here is a signal read, so the component refreshes precisely when a value, error or flag changes, which fits the `OnPush`-by-default, zoneless-by-default setup of current Angular.
- Why does adding a required attribute to an <input> that already has [formField] fail to compile?The `FormField` directive owns the element's value and constraint properties, so the template type-checker reports binding `[value]`, `[disabled]` or setting attributes like `required` on the same element as an error. The rule belongs in the schema: `required(path.email)` both validates and mirrors the `required` attribute onto the native input.
- How do you reset the registration form after a successful save?Set the model back to its initial object with `model.set(...)`, and call `registrationForm().reset()` to clear the touched and dirty flags. `reset()` on its own does not change the data unless you pass it a value, because the model signal, not the form, owns the data.
saying these in an interview costs you the question
- form() copies the model and writes it back to the signal only on submit
- The schema function re-runs on every keystroke to rebuild the rules
- Signal Forms builds FormControl and FormGroup instances under the hood for each field
- You still add required and disabled attributes in the template alongside [formField]
- Signal Forms is still an experimental API in Angular 22