skip to content

In Angular Signal Forms, how do form(), a schema function and the [formField] directive work together in a registration form?

level: juniorimportance: must knowfreq 48%

answer

  1. the model is a writable signal
  2. a field tree mirrors its shape
  3. rules bound to paths, once
  4. call a field to read its state

basics

~20 s

The 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 s

In 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 lines
ts
import {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

for a junior

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.

for a middle

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.

for a senior

Discuss model design constraints (plain objects, initial values for every field, '' for text) and why constraint attributes move from the template into the schema.

for a principal

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