Angular Signal Forms (v21): The Future of Form Handling Is Here
angular UI/UX frontend

Angular Signal Forms (v21): The Future of Form Handling Is Here

D. Rout

D. Rout

February 12, 2026 9 min read

On this page

⚠️ Experimental Status: Signal Forms are experimental as of Angular v21. The API may change in future releases. Evaluate carefully before using in production.

1. A Brief History of Angular Forms {#history}

Angular has had not one, but two form systems baked into the framework since its early days — and both have left developers with a complicated relationship with the framework.

Template-Driven Forms arrived first, shipping as part of @angular/forms alongside Angular 2's initial release in 2016. They leveraged the familiar ngModel directive and kept form logic close to the template. Developers coming from AngularJS felt right at home, but the approach quickly showed its limits: testing was painful, logic was hidden in the DOM, and anything beyond a simple form required creative workarounds.

Reactive Forms were introduced as the answer to that complexity. Built around FormGroup, FormControl, and FormArray, they gave developers explicit, imperative control over form state. They integrated well with RxJS observables, supported complex cross-field validation, and were far easier to unit test. For a long time, Reactive Forms were the idiomatic Angular choice for any serious application.

But as Angular evolved — adopting standalone components, the new control flow syntax, and most significantly, Signals — the cracks in Reactive Forms began to show. The FormGroup/FormControl model was fundamentally imperative and RxJS-centric. It never felt reactive in the way signals are reactive. Developers reached for toSignal(form.valueChanges) as a workaround, but that's patching a leaky pipe, not fixing the plumbing.

The Angular team announced Signal Forms at ng-Poland in 2024, and after roughly a year of development, they landed in Angular v21 in November 2025 as an experimental feature. The goal: combine the ergonomics of template-driven forms with the power of reactive forms, built entirely on the signal foundation Angular has been investing in since v16.


2. What Are Signal Forms? {#what}

Signal Forms are a new form management system, shipped as part of the existing @angular/forms package, imported from the @angular/forms/signals sub-path. They manage form state using Angular's native signal primitives, providing automatic two-way synchronisation between your data model and the UI — without RxJS, without FormGroup, and without the ControlValueAccessor ceremony.

At its core, a Signal Form starts with a signal holding your data model. You pass that signal to the form() function, attach the FormField directive to your inputs in the template, and Angular handles the rest.

Key capabilities:

  • Automatic two-way binding between signal state and form fields
  • Full TypeScript type safety across the entire form model
  • Schema-based validation — all rules defined in one centralised place
  • Built-in field state tracking (touched, dirty, valid, errors)
  • Async validation support via validateAsync
  • Standard Schema compatibility (works with Zod and other schema libraries)
  • Zero RxJS dependency in the core flow

3. Why Signal Forms? {#why}

The Problem with Reactive Forms

Reactive Forms are powerful, but they carry significant cognitive overhead:

// The "normal" way — a lot of ceremony
this.form = this.fb.group({
  email: ['', [Validators.required, Validators.email]],
  password: ['', [Validators.required, Validators.minLength(8)]],
});
 
// Want this as a signal? You need a bridge
this.formValue = toSignal(this.form.valueChanges, {
  initialValue: this.form.value,
});

The FormGroup model is not reactive in the signal sense — it's an observable-backed imperative object. You push values in; you subscribe to values out. Every cross-field validation, every disabled/enabled toggle, every programmatic update demands careful orchestration of subscriptions and side effects.

Custom form controls are perhaps the biggest pain point. Implementing ControlValueAccessor requires registering onChange and onTouched callbacks through an internal wiring system that is genuinely confusing on first encounter — those "no-op" functions are later replaced by the parent form, meaning your control delegates state changes upward in a non-obvious way.

What Signal Forms Fix

Signal Forms treat the form as what it really is: a reactive view over a data signal. Your model lives in a signal. The form reads from it and writes back to it automatically. There is no subscription to manage, no valueChanges to pipe, and no ControlValueAccessor to implement for custom inputs.

The result is less boilerplate, better type safety, and a model that plays naturally with Angular's broader signal ecosystem — including computed(), effect(), and linkedSignal().


4. How They Work {#how}

The Three-Part Mental Model

  1. A signal holds your data model — this is your source of truth
  2. form() wraps the signal — creating a form object with state tracking
  3. FormField binds inputs — the directive wires HTML inputs to the signal fields

Setup

Signal Forms ship inside @angular/forms. No extra package installs needed.

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

Add FormField to your component imports:

@Component({
  selector: 'app-my-form',
  standalone: true,
  imports: [FormField],
  templateUrl: './my-form.component.html',
})

Validation Schemas

Validation is defined via a schema function — a single place that describes all rules for your model. This keeps validation logic out of the template and out of individual field definitions.

import { schema, required, email, minLength } from '@angular/forms/signals';
 
const userSchema = schema<User>((path) => {
  required(path.name);
  required(path.email);
  email(path.email);
  minLength(path.password, 8);
});

5. Code Examples {#code}

Example 1: Basic Login Form

// login.component.ts
import { Component, signal } from '@angular/core';
import { form, FormField, required, email } from '@angular/forms/signals';
 
interface LoginModel {
  email: string;
  password: string;
}
 
@Component({
  selector: 'app-login',
  standalone: true,
  imports: [FormField],
  templateUrl: './login.component.html',
})
export class LoginComponent {
  // 1. Your data model lives in a signal
  protected readonly model = signal<LoginModel>({
    email: '',
    password: '',
  });
 
  // 2. Pass the signal to form() with optional validation schema
  protected readonly loginForm = form(this.model, {
    schema: (path) => {
      required(path.email);
      email(path.email);
      required(path.password);
    },
  });
 
  submit() {
    if (this.loginForm.valid()) {
      console.log('Form value:', this.model());
    }
  }
}
<!-- login.component.html -->
<form (ngSubmit)="submit()">
  <div>
    <label for="email">Email</label>
    <!-- FormField directive wires the input to the signal field -->
    <input id="email" type="email" [FormField]="loginForm.fields.email" />
    @if (loginForm.fields.email.errors().required) {
      <span class="error">Email is required.</span>
    }
    @if (loginForm.fields.email.errors().email) {
      <span class="error">Enter a valid email address.</span>
    }
  </div>
 
  <div>
    <label for="password">Password</label>
    <input id="password" type="password" [FormField]="loginForm.fields.password" />
    @if (loginForm.fields.password.errors().required) {
      <span class="error">Password is required.</span>
    }
  </div>
 
  <button type="submit" [disabled]="!loginForm.valid()">Log In</button>
</form>

Example 2: Registration Form with Nested Schema

Signal Forms support nested object models cleanly, with full type safety at every level of nesting.

// registration.component.ts
import { Component, signal } from '@angular/core';
import {
  form,
  FormField,
  schema,
  required,
  email,
  minLength,
} from '@angular/forms/signals';
 
interface Address {
  street: string;
  city: string;
}
 
interface RegistrationModel {
  fullName: string;
  email: string;
  password: string;
  address: Address;
}
 
const registrationSchema = schema<RegistrationModel>((path) => {
  required(path.fullName);
  required(path.email);
  email(path.email);
  minLength(path.password, 8);
  required(path.address.city);
});
 
@Component({
  selector: 'app-registration',
  standalone: true,
  imports: [FormField],
  templateUrl: './registration.component.html',
})
export class RegistrationComponent {
  protected readonly model = signal<RegistrationModel>({
    fullName: '',
    email: '',
    password: '',
    address: { street: '', city: '' },
  });
 
  protected readonly registrationForm = form(this.model, {
    schema: registrationSchema,
  });
 
  submit() {
    if (this.registrationForm.valid()) {
      // model() gives you the typed, up-to-date value
      console.log('Registering:', this.model());
    }
  }
}
<!-- registration.component.html -->
<form (ngSubmit)="submit()">
  <input type="text" [FormField]="registrationForm.fields.fullName" placeholder="Full Name" />
 
  <input type="email" [FormField]="registrationForm.fields.email" placeholder="Email" />
  @if (registrationForm.fields.email.touched() && registrationForm.fields.email.errors().email) {
    <span class="error">Please enter a valid email.</span>
  }
 
  <input type="password" [FormField]="registrationForm.fields.password" placeholder="Password" />
  @if (registrationForm.fields.password.errors().minLength) {
    <span class="error">Password must be at least 8 characters.</span>
  }
 
  <!-- Nested object fields work identically -->
  <input type="text" [FormField]="registrationForm.fields.address.street" placeholder="Street" />
  <input type="text" [FormField]="registrationForm.fields.address.city" placeholder="City" />
 
  <button type="submit" [disabled]="!registrationForm.valid()">Register</button>
</form>

Example 3: Async Validation

Signal Forms provide validateAsync for server-side or async validation flows:

// username-availability.validator.ts
import { SchemaPathTree, validateAsync } from '@angular/forms/signals';
import { resource } from '@angular/core';
 
export function validateUsernameAvailable(path: SchemaPathTree<string>) {
  validateAsync(path, {
    params: (ctx) => ({ username: ctx.value() }),
    factory: (params) =>
      resource({
        params,
        loader: async ({ params }) => {
          const res = await fetch(`/api/check-username?username=${params.username}`);
          return res.json() as Promise<{ available: boolean }>;
        },
      }),
    onSuccess: (result, _ctx) => {
      if (!result.available) {
        return { kind: 'username_taken' };
      }
      return null;
    },
    onError: (_err, _ctx) => ({ kind: 'server_error' }),
  });
}
// In your component schema
const signupSchema = schema<SignupModel>((path) => {
  required(path.username);
  validateUsernameAvailable(path.username);
});

Example 4: Standard Schema Integration (Zod)

If you already have Zod schemas — for instance, shared with a backend — Signal Forms can validate against them directly:

import { z } from 'zod';
import { schema, validateStandardSchema } from '@angular/forms/signals';
 
const ProductZodSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  price: z.number().positive('Price must be positive'),
  sku: z.string().regex(/^[A-Z]{2}\d{4}$/, 'SKU format: XX0000'),
});
 
type Product = z.infer<typeof ProductZodSchema>;
 
export const productSchema = schema<Product>((path) => {
  // One line — reuses your existing Zod schema
  validateStandardSchema(path, ProductZodSchema);
});

Accessing Field State

Every field exposes signal-based state properties you can use directly in templates or in computed() / effect():

const field = myForm.fields.email;
 
field.value()      // current value
field.valid()      // boolean
field.invalid()    // boolean
field.touched()    // user has interacted
field.dirty()      // value has changed
field.errors()     // object of active error keys
field.pending()    // async validation in progress

Because these are signals, Angular's change detection picks them up automatically with OnPush components — no markForCheck() required.


6. Migration Notes {#migration}

Signal Forms are designed for new applications and new features. The Angular team has published a migration guide for teams moving from Reactive Forms:

  • Signal Forms do not use FormGroup or FormControl — you cannot mix them
  • If you rely heavily on RxJS operators chained to valueChanges, that pattern has no direct equivalent; the reactive model is now signal-based
  • ControlValueAccessor custom controls have a different (simpler) integration path in Signal Forms — see the custom controls guide

For existing applications, Reactive Forms remain fully supported with no deprecation timeline announced. A measured approach is to use Signal Forms for new features while leaving existing Reactive Forms in place.


Further Learning {#further-learning}

Official Angular Documentation

Community Articles & Deep Dives

Background: Understanding Angular Signals


Angular Signal Forms are experimental in v21. Always check the Angular release notes for API changes before upgrading.

Share

Comments (0)

Join the conversation

Sign in to leave a comment on this post.

No comments yet. to be the first!