All posts

Angular Signal Forms — a beginner's guide

Jun 29, 2026 3 min read
Share

Angular forms used to mean a choice: template-driven (simple, but loosely typed) or reactive (FormControl/FormGroup — powerful, but plenty of boilerplate). Signal Forms is a third option that drops both. You start from a plain signal holding your data, wrap it in form(), and bind fields straight to inputs. Validation, dirty/touched state, and errors all fall out as signals you can read anywhere.

Signal Forms ships from @angular/forms/signals and is stable as of Angular v22 (it landed experimental in v21). For new forms on v22+, it’s a production-ready choice.

The mental model

Three pieces, that’s it:

  • A model signalsignal({...}) holding your form’s data.
  • form(model, schema) — wraps the model and returns a form object. The optional schema function declares validation rules.
  • [formField] — a directive (imported as FormField) that binds one field to a native input, wiring value and state in both directions.

Every field exposes its state as signals: .value(), .valid(), .invalid(), .touched(), .errors(). No subscriptions, no valueChanges — read them in templates or computed() directly.

Step 1 — A minimal form

Start with just a model and a binding. No validation yet:

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

@Component({
	selector: 'app-profile',
	imports: [FormField],
	template: `
		<label>
			Name
			<input [formField]="profileForm.name" />
		</label>

		<p>Hello, {{ profileForm.name().value() }}</p>
	`
})
export class Profile {
	profileModel = signal({ name: '' });
	profileForm = form(this.profileModel);
}

Type into the input and the <p> updates — profileForm.name().value() is a signal tracking the field. The model signal stays the single source of truth.

Step 2 — Add validation

Pass a schema function as the second argument to form(). It receives a path object; apply built-in validators (required, email, minLength, min, max, …) to each field:

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

@Component({
	selector: 'app-login',
	imports: [FormField],
	template: `
		<form (submit)="onSubmit($event)">
			<label>
				Email
				<input type="email" [formField]="loginForm.email" />
			</label>
			@if (loginForm.email().touched() && loginForm.email().invalid()) {
				<span class="error">{{ loginForm.email().errors()[0].message }}</span>
			}

			<label>
				Password
				<input type="password" [formField]="loginForm.password" />
			</label>
			@if (loginForm.password().touched() && loginForm.password().invalid()) {
				<span class="error">{{ loginForm.password().errors()[0].message }}</span>
			}

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

	loginForm = form(this.loginModel, (path) => {
		required(path.email, { message: 'Email is required' });
		email(path.email, { message: 'Enter a valid email address' });

		required(path.password, { message: 'Password is required' });
		minLength(path.password, 8, { message: 'Password must be at least 8 characters' });
	});

	onSubmit(event: Event) {
		event.preventDefault();
		if (this.loginForm().valid()) {
			console.log('Submitting', this.loginModel());
		}
	}
}

Note the guards: touched() && invalid() shows the error only after the user has interacted with the field, not on first paint. The submit button reads loginForm().invalid() — call the form itself to get whole-form state.

Step 3 — Submitting

For the common case, the validity check above is enough. Signal Forms also ships a submit() helper that marks every field as touched and then runs your async action only when the form is valid:

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

onSubmit(event: Event) {
  event.preventDefault();
  submit(this.loginForm, async () => {
    await this.auth.login(this.loginModel());
  });
}

Field state cheat sheet

Every field is a signal-returning function — call it, then read the state signals:

Read thisTells you
form.field().value()Current value
form.field().valid()Passes all validators
form.field().invalid()Fails at least one validator
form.field().touched()User has focused then blurred the field
form.field().errors()Array of error objects (.message, .kind, …)
form().valid() / .invalid()Whole-form validity

Because these are plain signals, you can compose them — drop a computed() over errors(), gate a button on form().invalid(), or drive a progress bar off field state, all without manual wiring.

Where Signal Forms fits

  • Template-driven — fine for tiny forms, but weak typing and logic that hides in the template.
  • Reactive forms — battle-tested; still fully supported and a fine choice for existing code.
  • Signal Forms — the typed model and declarative validators of reactive forms, expressed as signals, with far less ceremony. Stable on Angular v22+ and the recommended default for new forms.

If you’re already writing signals-first Angular — signal, computed, inject(), @if/@for — Signal Forms is the forms layer that matches. The official Signal Forms guide covers the rest, including schema reuse, cross-field validation, and migrating reactive forms incrementally.

Wrap-up

The whole loop: hold data in a signal, wrap it with form(), declare validators in the schema, bind inputs with [formField], and read .valid() / .errors() / .touched() straight off each field. No FormControl, no subscriptions — just signals end to end.