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 tree of signals representing field values plus a small API around it. The most basic case — a login form:

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

@Component({
  selector: 'app-login',
  template: `
    <form (submit)="submit($event)">
      <input [value]="loginForm.email().value()" (input)="loginForm.email().value.set($any($event.target).value)" />
      @if (loginForm.email().errors().required) { <p>Email required</p> }
      @if (loginForm.email().errors().email) { <p>Invalid email</p> }

      <input type="password" [value]="loginForm.password().value()" (input)="loginForm.password().value.set($any($event.target).value)" />
      @if (loginForm.password().errors().minLength) { <p>At least 8 characters</p> }

      <button type="submit" [disabled]="!loginForm.valid()">Sign in</button>
    </form>
  `,
})
export class LoginComponent {
  loginForm = form({
    email: field('', [required(), email()]),
    password: field('', [required(), minLength(8)]),
  });

  submit(e: Event) {
    e.preventDefault();
    if (this.loginForm.valid()) {
      this.auth.login(this.loginForm.value());
    }
  }
}

Three mechanical facts:

  1. form() takes a schema — an object mapping field names to field() declarations
  2. Every field becomes a signal — read with (), value sub-signal with .value(), errors with .errors()
  3. The whole form is reactiveloginForm.valid(), loginForm.value(), loginForm.dirty() are all signals

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(schema) Build a form tree from a field schema
field<T>(initial, validators?) Declare one form field with an initial value and validators
array<T>(initial, itemSchema) Declare a repeatable list of fields
required() / email() / minLength(n) / maxLength(n) / pattern(re) / min(n) / max(n) Built-in validators
`custom((value) => error null)`
asyncValidator(fn) Async validation (e.g. unique-username check)

All validators are first-class functions you compose into the field declaration's second argument. No Validators.required static-method namespace.

Reading form state #

Every field exposes a typed shape:

const f = this.loginForm.email();
//  f.value()     -> string                  (signal)
//  f.errors()    -> { required?: true, email?: true }
//  f.valid()     -> boolean                 (true if no errors)
//  f.touched()   -> boolean                 (true after first blur)
//  f.dirty()     -> boolean                 (true if user has changed value)

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:

this.loginForm.set({ email: 'a@b.c', password: 'secret123' });
this.loginForm.reset();      // back to initial values, clears dirty/touched

Custom validators #

The shape — a function that takes the field value and returns an error object or null:

import { field, required, custom } from '@angular/forms/signals';

const passwordsMatch = custom((value: string, ctx) => {
  return value === ctx.form.password().value() ? null : { mismatch: true };
});

confirmPassword = field('', [required(), passwordsMatch]);

The ctx parameter gives you the broader form tree for cross-field validation. The returned object's keys appear in .errors().

Async validation #

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

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

const uniqueUsername = asyncValidator(async (value: string) => {
  if (!value) return null;
  const taken = await fetch(`/api/users/check?name=${value}`).then(r => r.json());
  return taken ? { taken: true } : null;
});

username = field('', [required(), minLength(3), uniqueUsername]);

The field exposes .pending() while the async validator is running:

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

Async validators are debounced automatically — typing fast doesn't fire the server check on every keystroke.

Field arrays #

For a list of repeated fields (todo items, addresses, line items):

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

this.invoiceForm = form({
  customer: field('', [required()]),
  items: array<{ name: string; qty: number }>(
    [{ name: '', qty: 1 }],
    {
      name: field('', [required()]),
      qty:  field(1, [min(1)]),
    },
  ),
});

// Add/remove
this.invoiceForm.items().add({ name: '', qty: 1 });
this.invoiceForm.items().remove(0);
this.invoiceForm.items().count();   // signal — current array length

In the template:

@for (item of invoiceForm.items().controls(); track $index) {
  <div>
    <input [value]="item.name().value()" (input)="item.name().value.set($any($event.target).value)" />
    <input type="number" [value]="item.qty().value()" (input)="item.qty().value.set(+$any($event.target).value)" />
  </div>
}

Far cleaner than the FormArray of legacy reactive forms.

Wiring to native inputs #

The [value]="f.x().value()" (input)="f.x().value.set(...)" boilerplate adds up. The standard pattern is to wrap it in a tiny directive or input wrapper component. Lesson 4.5 covers form input wrappers in depth; for now, the verbose-but-explicit binding above works.

For third-party components (date pickers, custom selects), use the same two-way binding pattern — (input) becomes whatever event the component emits.

Comparing the three forms eras #

Aspect Template-driven Reactive forms Signal forms
Define controls in Template ([(ngModel)]) Class (new FormGroup) Class (form({...}))
Validators Directives (required, minlength) Functions (Validators.required) Functions (required())
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
form.field() reads undefined Field name typo or schema missing the field Check the schema key matches
Validator runs but error doesn't display Reading .errors() without the key .errors().required not .errors().requiredError
Form value out of sync with input Native <input> [value] is one-way; need (input) to write back Always pair [value]=... with (input)=...
Async validator fires too often Default debouncing should handle it; check that the field's value really is stable Verify with effect(() => console.log(form.x().value()))
form.set(...) throws Provided value doesn't match the schema shape The whole-form set requires every field to have a value matching its type

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 } from '@angular/core';
import { form, field, required, email, minLength, custom } from '@angular/forms/signals';

@Component({
  selector: 'app-contact',
  template: `
    <form (submit)="send($event)">
      <input placeholder="Email" [value]="f.email().value()" (input)="f.email().value.set($any($event.target).value)" />
      <input placeholder="Confirm email" [value]="f.confirm().value()" (input)="f.confirm().value.set($any($event.target).value)" />
      @if (f.confirm().errors().mismatch) { <p>Emails don't match</p> }

      <textarea placeholder="Message" [value]="f.msg().value()" (input)="f.msg().value.set($any($event.target).value)"></textarea>

      <button type="submit" [disabled]="!f.valid()">Send</button>
    </form>
  `,
})
export class ContactComponent {
  f = form({
    email: field('', [required(), email()]),
    confirm: field('', [required(), custom((v, ctx) => v === ctx.form.email().value() ? null : { mismatch: true })]),
    msg: field('', [required(), minLength(10)]),
  });

  send(e: Event) {
    e.preventDefault();
    if (this.f.valid()) console.log('Sending:', this.f.value());
  }
}

Three fields, cross-field validation, type-safe values — and 20 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 →

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 *