Back to CourseIntermediate5 min read
Angular /

Signal Forms: form, FormField and Schemas

Build forms from a signal model with form and FormField, the signal based forms API that became stable in Angular 22.

Bishal Biswas

Bishal Biswas

Self Learner

Published on: June 17, 2026
Last updated: September 7, 2026
Intermediate Topic

A form derived from a signal

Signal Forms became stable in Angular 22. The idea: your data is a signal, and the form is derived from it.

import { Component, signal } from '@angular/core';
import { form, required, minLength, FormField } from '@angular/forms/signals';

@Component({
  selector: 'app-login',
  imports: [FormField],
  template: `
    <input [formField]="loginForm.email" type="email">
    <input [formField]="loginForm.password" type="password">

    <button [disabled]="loginForm().invalid()">Sign in</button>
  `,
})
export class Login {
  protected readonly credentials = signal({ email: '', password: '' });

  protected readonly loginForm = form(this.credentials, path => {
    required(path.email);
    required(path.password);
    minLength(path.password, 8);
  });
}

The model is the single source of truth. Typing in the input updates the signal, and setting the signal updates the input.

Note the import path: @angular/forms/signals, a separate entry point from the classic @angular/forms.

The two arguments to form

const myForm = form(modelSignal, schemaCallback);

The model is a writable signal holding an object describing the form’s shape.

The schema is a callback receiving a path object that mirrors the model. You attach validation and behaviour to paths on it.

protected readonly model = signal({
  name: '',
  email: '',
  age: 0,
});

protected readonly userForm = form(this.model, path => {
  required(path.name);
  required(path.email);
  email(path.email);
  min(path.age, 18);
});

path.name is typed from the model, so a typo is a compile error rather than a silent no-op.

Never initialise with null

This is the rule that catches everyone.

// wrong
signal({ name: null, age: null, tags: null });

// right
signal({ name: '', age: 0, tags: [] as string[] });

The form structure is derived from the model’s shape. A null gives it nothing to work with. Use an empty string for text, 0 for numbers and [] for arrays.

FormField versus FieldState

The distinction that trips people up.

loginForm.email          // FormField: the structure
loginForm.email()        // FieldState: the signals
loginForm.email().value()   // the actual value

Calling a field as a function gives you its state. The field itself is structural and has no valid or errors on it.

loginForm.email().valid()      // correct
loginForm.email.valid()        // wrong, FormField has no such method

Once that clicks, the rest of the API follows.

Reading field state

const email = this.loginForm.email();

email.value();      // the current value
email.valid();      // passes every validator
email.invalid();
email.errors();     // an array of validation errors
email.touched();    // has been blurred
email.dirty();      // has been changed
email.disabled();
email.readonly();
email.hidden();

The whole form has the same state:

this.loginForm().valid();
this.loginForm().value();     // the model object
this.loginForm().dirty();

In templates

<label>
  Email
  <input [formField]="loginForm.email" type="email">
</label>

@if (loginForm.email().touched() && loginForm.email().invalid()) {
  @for (error of loginForm.email().errors(); track error.kind) {
    <p class="error">{{ error.message ?? error.kind }}</p>
  }
}

Checking touched() before showing errors is what stops every field turning red the moment the form appears.

[formField] handles the two way binding, and also syncs required, disabled and readonly onto the element. So do not set those attributes yourself:

<!-- wrong, it fights the directive -->
<input [formField]="form.email" [disabled]="busy()" required>

<!-- right, express it in the schema -->
<input [formField]="form.email">
form(this.model, path => {
  required(path.email);
  disabled(path.email, () => this.busy());
});

Also avoid [value] on text and number inputs, since the directive owns the value. The exception is radios and checkboxes, where value identifies which option the input represents.

Reusable schemas

import { schema } from '@angular/forms/signals';

export const addressSchema = schema<Address>(path => {
  required(path.line1);
  required(path.city);
  required(path.postcode);
  pattern(path.postcode, /^[A-Z0-9 ]{5,8}$/i);
});
protected readonly deliveryForm = form(this.address, addressSchema);

The same schema can be applied to a nested part of a larger form with apply, which the nested fields topic covers.

Submitting

import { submit } from '@angular/forms/signals';

@Component({
  template: `
    <form (ngSubmit)="save()">
      <input [formField]="userForm.name">
      <button [disabled]="userForm().invalid()">Save</button>
    </form>
  `,
})
export class UserForm {
  private readonly http = inject(HttpClient);

  protected readonly model = signal({ name: '' });
  protected readonly userForm = form(this.model, path => required(path.name));

  protected async save(): Promise<void> {
    await submit(this.userForm, async () => {
      await firstValueFrom(this.http.post('/api/users', this.model()));
    });
  }
}

submit marks every field as touched before running, so validation errors appear for fields the user never visited. That is exactly what you want when someone clicks Save on an empty form.

The callback must be async and return a promise.

Resetting

protected reset(): void {
  this.model.set({ name: '', email: '' });
}

Because the model is the source of truth, resetting is just setting the signal. There is no separate form reset to remember.

Loading an existing record works the same way:

protected load(user: User): void {
  this.model.set({ name: user.name, email: user.email });
}

Compared with reactive forms

Reactive forms                     Signal forms
new FormGroup({ ... })             signal({ ... }) + form()
FormControl, FormGroup, FormArray  none of these
form.get('email')                  form.email
form.value                         model() or form().value()
valueChanges observable            the model signal itself
Validators.required                required(path.email)
[formControlName]="'email'"        [formField]="form.email"
form.controls['email'].errors      form.email().errors()

The biggest practical difference: there is no separate form object holding a copy of the data. The model is the data, so there is nothing to keep in sync.

Quick recap

  • Signal Forms are stable in Angular 22 and live in @angular/forms/signals.
  • form(model, schema) derives a form from a writable signal.
  • Never initialise model fields with null, use '', 0 and [].
  • Call a field as a function to reach its state: form.email().valid().
  • [formField] handles binding and syncs required, disabled and readonly.
  • Do not set disabled, required or [value] yourself, express them in the schema.
  • submit marks every field touched, so errors appear on an untouched form.
app.ts

import { Component, signal, inject } from '@angular/core';
import { form, schema, submit, required, email, minLength, min, disabled,
         FormField } from '@angular/forms/signals';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
@Component({
  selector: 'app-user-form',
  imports: [FormField],
  template: `
    <form (ngSubmit)="save()">
      <!-- [formField] owns the value AND syncs required/disabled/readonly.
           Do NOT also set [value], [disabled] or required yourself. -->
      <input [formField]="userForm.name">
      <input [formField]="userForm.email" type="email">
      <!-- checking touched() stops every field turning red immediately -->
      @if (userForm.email().touched() && userForm.email().invalid()) {
        @for (error of userForm.email().errors(); track error.kind) {
          <p class="error">{{ error.message ?? error.kind }}</p>
        }
      }
      <button [disabled]="userForm().invalid()">Save</button>
    </form>
  `,
})
export class UserForm {
  private readonly http = inject(HttpClient);
  protected readonly busy = signal(false);
  // NEVER initialise with null. The form shape is derived from the model.
  // Use '' for text, 0 for numbers, [] for arrays.
  protected readonly model = signal({ name: '', email: '', age: 0 });
  protected readonly userForm = form(this.model, path => {
    required(path.name);
    required(path.email);
    email(path.email);
    min(path.age, 18);
    disabled(path.email, () => this.busy());   // express it HERE, not in the template
  });
  protected async save(): Promise<void> {
    // submit marks EVERY field touched first, so errors show on an
    // untouched form. The callback must be async.
    await submit(this.userForm, async () => {
      await firstValueFrom(this.http.post('/api/users', this.model()));
    });
  }
  // The model IS the data, so resetting and loading are just signal sets
  protected reset(): void { this.model.set({ name: '', email: '', age: 0 }); }
}
// THE KEY DISTINCTION: FormField is structural, FieldState holds the signals.
// CALL the field to reach its state:
//   userForm.email          FormField, the structure
//   userForm.email()        FieldState
//   userForm.email().value()   valid()  invalid()  errors()  touched()  dirty()
//   userForm.email.valid()     WRONG, FormField has no such method
// Reusable schemas compose into larger forms:
const addressSchema = schema<{ city: string }>(path => required(path.city));

Was this explanation helpful?

Your feedback helps us improve the content for everyone.