Angular PrimerAngular 22 · 2022 → 2026

Forms

Typed reactive forms are still here and still good. Signal Forms are the new option: validators and submit state as signals, bound with one directive.

Where forms are today

There are three ways to build a form, and they are not mutually exclusive:

  • Template-driven (ngModel) — fine for a two-field settings panel, awkward for anything with rules.
  • Typed reactive forms (FormGroup, FormControl) — the workhorse since v14. Typed, testable, explicit.
  • Signal Forms (@angular/forms/signals) — the v22 addition. State and validation are signals, and the template binds fields with [formField].

Typed reactive forms, briefly

The thing that changed after 2022 is that the types are real: a control knows whether it is string or string | null, and nonNullable keeps the null out of the way:

2026 — a typed reactive form
readonly form = new FormGroup({
  email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }),
  age: new FormControl<number | null>(null, { validators: [Validators.min(18)] }),
});

readonly emailInvalid = computed(() => this.form.controls.email.invalid && this.form.controls.email.touched);

submit() {
  if (this.form.invalid) { this.form.markAllAsTouched(); return; }
  this.api.save(this.form.getRawValue());   // fully typed
}

getRawValue() returns the typed shape, so the compiler catches a renamed field. This is still the right choice for a large, dynamic, FormArray-heavy form.

Typed reactive forms, live

FormGroup with nonNullable controls and Validators. Type a bad email to see the validator message and the disabled submit; a valid one renders the typed getRawValue().

form.status = INVALID · valid = false

Signal Forms

Signal Forms turn the model and its validation into signals, with the schema declared once next to the data:

2026 — a signal form
import { form, required, email, minLength, FormField, submit } from '@angular/forms/signals';

export class Signup {
  readonly model = signal({ email: '', password: '', name: '' });

  readonly signup = form(this.model, (path) => {
    required(path.email, { message: 'An email address is required.' });
    email(path.email, { message: 'That does not look like an email address.' });
    required(path.password);
    minLength(path.password, 10, { message: 'Use at least 10 characters.' });
    required(path.name);
  });

  async save() {
    const ok = await submit(this.signup, async (field) => {
      await this.api.register(field().value());    // the action gets the field tree; value() is the model
    });
    if (!ok) this.signup().markAsTouched();        // submit() reports the failure instead of throwing
  }
}

The template binds fields with [formField] and reads state as signals:

2026 — binding a signal field
<form (submit)="save($event)">
  <label for="email">Email</label>
  <input id="email" type="email" [formField]="signup.email" />
  @if (signup.email().touched() && signup.email().invalid()) {
    <p class="error">{{ signup.email().errors()[0]?.message }}</p>
  }

  <button type="submit" [disabled]="signup().invalid() || signup().submitting()">
    {{ signup().submitting() ? 'Creating…' : 'Create account' }}
  </button>
</form>

Two things are worth pointing out about that template. signup.email() is a field signal groupvalue(), errors(), touched(), invalid() — so the template reads validation the same way it reads anything else. And signup().invalid() / submitting() are ordinary signals, which means the submit button reacts without a FormGroup.statusChanges subscription.

Signal Forms: schema, field state, submit

The schema declares the rules next to the model, [formField] binds the inputs, and the field state is a signal group. submit() reports the failure instead of throwing.

form state: invalid

Validators

The built-in validators (required, email, minLength, maxLength, min, max, pattern) are imported as functions rather than reached through Validators.x, each taking an options object with a message. A custom rule is a function that writes an error onto the field:

2026 — a custom validator
function notDisposable(path: Field<string>) {
  validate(path, ({ value }) => {
    if (!value().includes('@example.com')) return null;
    return { kind: 'disposable', message: 'Use a real address, not a throwaway one.' };
  });
}

Conditional rules read other fields — applyWhen(path.company, () => this.model().wantsInvoice, ...) — which is how "required only if" is expressed without a cross-field ValidatorFn and manual re-validation.

Which to reach for

  • Signal Forms for new, form-shaped UI: it is less code, the validation is colocated with the model, and the template reads like the rest of a signals app.
  • Typed reactive forms where the form is genuinely dynamic — rows added and removed, nested arrays, schema built at runtime — or where a large existing test suite already covers FormGroup behaviour.
  • ngModel for a search box and a checkbox in a settings panel, and nothing more.

Both reactive forms and Signal Forms are supported, so this is not a migration you have to do in one go — a Signal Form can live next to a FormGroup in the same app, and the same validators ideas apply in both.

If you come from reactive forms, the mistake to avoid is treating the model signal as the form state. this.model() is still just data — bind it to [formField] and read validation from the schema (signup.email().errors()), not from the model. Writing to this.model.set(...) is how you reset or prefill a field; reading it tells you nothing about whether the value is valid.

What to take away

Forms got the treatment the rest of the framework got: state you can read, validation as a declarative schema, and submit state as a signal instead of a status-change stream. That closes the loop with HTTP & async data, where the same objects are sent to the server.