Angular Signal Forms Explained: The 2026 Forms API Replacing valueChanges + takeUntil [2026]

Link copied
Angular Signal Forms Explained: The 2026 Forms API Replacing valueChanges + takeUntil [2026]

Angular Signal Forms Explained: The 2026 Forms API Replacing valueChanges + takeUntil [2026]

Angular Tutorial › Module 4: Forms › Lesson 4.1

Angular's forms story has had three eras: template-driven (v2+), reactive forms with FormGroup/FormControl (v2+), and as of v22 — signal forms. The new API takes the lessons from the signal system (lesson 3.1) and applies them to form state. The result: no more valueChanges.pipe(takeUntil(destroy$)), no more setValue vs patchValue confusion, no more loose typing on controls. Just signals.

This is lesson 4.1 of the Angular Tutorial, opening Module 4. Even though it sits in a Beginner-friendly slot, it represents the modern shape of forms in 2026. Lessons 4.2 and 4.3 cover Reactive Forms and Template-Driven Forms for migration cases; this lesson is what you reach for in new code.

The shape #

A signal form is a model signal plus a field tree that form() derives from it. The most basic case — a login form:

import { Component, inject, signal } from '@angular/core';
import { form, FormField, FormRoot, required, minLength, email } from '@angular/forms/signals';
import { AuthService } from './auth.service';

@Component({
  selector: 'app-login',
  imports: [FormField, FormRoot],
  template: `
    <form [formRoot]="loginForm">
      <input type="email" [formField]="loginForm.email" />
      @if (loginForm.email().touched() && loginForm.email().invalid()) {
        @for (error of loginForm.email().errors(); track error) { <p>{{ error.message }}</p> }
      }

      <input type="password" [formField]="loginForm.password" />

      <button type="submit" [disabled]="loginForm().invalid()">Sign in</button>
    </form>
  `,
})
export class LoginComponent {
  private auth = inject(AuthService);

  loginModel = signal({ email: '', password: '' });

  loginForm = form(
    this.loginModel,
    (s) => {
      required(s.email, { message: 'Email required' });
      email(s.email, { message: 'Invalid email' });
      minLength(s.password, 8);
    },
    { submission: { action: async (f) => { await this.auth.login(f().value()); } } },
  );
}

Three mechanical facts:

  1. form() takes a model signal and a schema function — the schema binds validators to paths like s.email
  2. Every field is callable — loginForm.email() returns its state: .value(), .errors(), .touched()
  3. The whole form is reactive — loginForm().valid(), loginForm().value(), loginForm().dirty() are all signals; [formField] syncs inputs both ways

Replaces the legacy FormGroup / FormControl / Validators trio with one cohesive API.

The primitives #

Signal forms ship from @angular/forms/signals:

Primitive What it does
form(model, schema?, options?) Build a field tree from a model signal
FormField ([formField]) Two-way binds an input to a field
FormRoot ([formRoot]) Handles <form> submission
required() / email() / minLength() / maxLength() / pattern() / min() / max() Built-in validators: minLength(s.password, 8)
validate(path, fn) Custom validator returning { kind } or null
validateHttp() / validateAsync() Async validation (e.g. unique-username check)
applyEach(path, itemSchema) Rules for every array item

All validators are plain functions you call inside the schema function, passing a path. No Validators.required static-method namespace.

Reading form state #

Every field exposes a typed shape:

const f = this.loginForm.email();
//  f.value()     -> string                  (writable signal)
//  f.errors()    -> ValidationError[]       ([{ kind: 'required', message: '...' }])
//  f.valid()     -> boolean                 (no errors, nothing pending)
//  f.touched()   -> boolean                 (true after first blur)
//  f.dirty()     -> boolean                 (true if user has changed value)

The root of the field tree is a field too, so the whole form aggregates these:

this.loginForm().value();      // { email: string, password: string }
this.loginForm().valid();      // true if every field is valid
this.loginForm().dirty();      // true if any field is dirty
this.loginForm().touched();    // true if any field has been blurred

Everything is a signal. Templates auto-track. effect() can react. computed() can derive.

Writing field values #

Field values are writable signals — .value.set(...) and .value.update(...):

this.loginForm.email().value.set('new@example.com');
this.loginForm.password().value.update(v => v.trim());

For a whole-form update, write to the model signal:

this.loginModel.set({ email: 'a@b.c', password: 'secret123' });
this.loginForm().reset({ email: '', password: '' });  // sets the value, clears dirty/touched

Custom validators #

The shape — validate() with a function that returns an error object (kind, optional message) or null:

import { form, required, validate } from '@angular/forms/signals';

signupForm = form(this.signupModel, (s) => {
  required(s.confirmPassword);
  validate(s.confirmPassword, ({ value, valueOf }) =>
    value() === valueOf(s.password)
      ? null
      : { kind: 'mismatch', message: 'Passwords do not match' },
  );
});

valueOf() reads another field by path — that's cross-field validation, re-run when either field changes. The error appears in .errors().

Async validation #

For server-side checks (username available, email not in use):

import { form, required, minLength, validateHttp } from '@angular/forms/signals';

signupForm = form(this.signupModel, (s) => {
  required(s.username);
  minLength(s.username, 3);
  validateHttp(s.username, {
    debounce: 300,
    request: ({ value }) => (value() ? `/api/users/check?name=${value()}` : undefined),
    onSuccess: (res: { taken: boolean }) =>
      res.taken ? { kind: 'taken', message: 'Username is taken' } : null,
    onError: () => ({ kind: 'serverError', message: 'Could not check username' }),
  });
});

The field exposes .pending() while the request is running (validateHttp() needs provideHttpClient()):

@if (signupForm.username().pending()) { <span>Checking...</span> }

Async rules run only after the sync rules pass, and a new value cancels the in-flight request. Debouncing is opt-in via debounce: 300.

Field arrays #

Put an array in the model and the field tree mirrors it. Apply per-item rules with applyEach():

import { form, applyEach, required, min } from '@angular/forms/signals';

invoiceModel = signal({
  customer: '',
  items: [{ name: '', qty: 1 }],
});

invoiceForm = form(this.invoiceModel, (s) => {
  required(s.customer);
  applyEach(s.items, (item) => {
    required(item.name);
    min(item.qty, 1);
  });
});

// Add/remove — update the model; the field tree follows
addItem() {
  this.invoiceModel.update(m => ({ ...m, items: [...m.items, { name: '', qty: 1 }] }));
}
removeItem(index: number) {
  this.invoiceModel.update(m => ({ ...m, items: m.items.filter((_, i) => i !== index) }));
}

In the template:

@for (item of invoiceForm.items; track item) {
  <div>
    <input [formField]="item.name" />
    <input type="number" [formField]="item.qty" />
    <button type="button" (click)="removeItem($index)">Remove</button>
  </div>
}

Far cleaner than the FormArray of legacy reactive forms.

Wiring to native inputs #

[formField] does the wiring: on native inputs, textareas, selects and checkboxes it syncs the value, marks the field touched on blur, and mirrors attributes like disabled and required from your schema. Just add FormField to the component's imports.

For custom components (date pickers, custom selects), implement FormValueControl<T> — a value = model<T>() signal — and [formField] binds to them like a native input.

Comparing the three forms eras #

Aspect Template-driven Reactive forms Signal forms
Define controls in Template ([(ngModel)]) Class (new FormGroup) Class (form(model, schema))
Validators Directives (required, minlength) Functions (Validators.required) Schema rules (required(s.email))
State exposed as Template variables (#ref="ngModel") Properties (form.get('x').value) Signals (form.x().value())
valueChanges for reactivity Observable Observable Signals + computed()
Cleanup on destroy Automatic Manual (takeUntil) Automatic (signals)
Type safety Weak Improved with FormGroup<T> (lesson 4.6) Strong, automatic
Best for in 2026 Tiny prototypes Legacy code maintenance All new code

For every new form in 2026: signal forms. Reactive forms remain valid; template-driven survives but is rarely the right call.

Common gotchas #

Symptom Cause Fix
loginForm.x is undefined Model has no x key, or it is undefined Initialize every field ('' or null)
Error never displays Reading .errors().required .errors() is an array — check error.kind
Input doesn't sync FormField missing from imports Add FormField to imports
Async validator fires on every keystroke No debounce configured Add debounce: 300 to validateHttp()
Validator throws when called Called outside the schema function Call validators only inside the function passed to form()

What's next #

Lesson 4.2 covers Reactive Forms — the FormGroup/FormControl API for legacy code and gradual migration. Lesson 4.3 covers template-driven forms. Lesson 4.4 dives into validation patterns (cross-field, async). Lesson 4.5 walks file uploads with progress. Lesson 4.6 covers typed Reactive Forms.

Try it yourself #

A tiny contact form with cross-field validation:

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

@Component({
  selector: 'app-contact',
  imports: [FormField, FormRoot],
  template: `
    <form [formRoot]="f">
      <input placeholder="Email" [formField]="f.email" />
      <input placeholder="Confirm email" [formField]="f.confirm" />
      @if (f.confirm().touched() && f.confirm().invalid()) { <p>{{ f.confirm().errors()[0].message }}</p> }

      <textarea placeholder="Message" [formField]="f.msg"></textarea>

      <button type="submit" [disabled]="f().invalid()">Send</button>
    </form>
  `,
})
export class ContactComponent {
  model = signal({ email: '', confirm: '', msg: '' });

  f = form(this.model, (s) => {
    email(s.email);
    validate(s.confirm, ({ value, valueOf }) =>
      value() === valueOf(s.email) ? null : { kind: 'mismatch', message: "Emails don't match" },
    );
    minLength(s.msg, 10);
  }, { submission: { action: async (field) => console.log('Sending:', field().value()) } });
}

Three fields, cross-field validation, type-safe values — 25 lines.

YouI have an existing app on reactive forms. Should I migrate to signal forms?
Claude · used get_best_practicesYes for new forms; opportunistically for old ones. New forms get all the benefits — type safety, signal reactivity, less boilerplate. For existing reactive forms that work, leave them. When you next touch the file to add a feature, that’s the moment to migrate the whole form. Don’t do a big-bang migration — both APIs coexist, and signal forms can wrap reactive controls when needed.

Lesson 4.2 picks up Reactive Forms — for legacy code and the cases where they still fit.

Up next in Angular

More from this topic

View all Angular articles →
Angular

When Angular is launched ?

Link copied Angular When Angular is launched ? February 8, 2024 · 1 min read When was Angular launched? # Angu…

Feb 8, 2024 Read →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *