Angular NotesRohit’s interview study guide
Chapter 12

Forms

Angular's three form approaches — Signal Forms (new in 22), Reactive Forms and Template-driven Forms — and when to use each.

20 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Three ways to build forms

A form is three problems glued together: state (what the user typed), rules (is it valid, is this field required, is that one disabled) and syncing that state with real <input> elements in both directions. Every Angular form approach solves the same three problems. What differs is where the source of truth lives.

From my notes: in Angular 22, Signal Forms are stable, so Angular now officially has three approaches:

ApproachSource of truthYou importBinding in the template
Signal Forms (new, stable in 22)A signal() holding your data modelFormField from @angular/forms/signals[formField]="signupForm.email"
Reactive FormsA tree of FormControl / FormGroup objects in the classReactiveFormsModule from @angular/forms[formGroup] + formControlName="email"
Template-driven FormsngModel directives in the template (they create the controls for you)FormsModule from @angular/forms[(ngModel)]="model.email" + name="email"

A mental model that helps: picture the same sign-up form built by three people. The Signal Forms person writes down the data ({ name, email, … }) and attaches rules to it — the form is a reactive view over that data. The Reactive Forms person builds a control panel object in TypeScript — every field is a FormControl with its own value, status and observables — and plugs the template into it. The template-driven person writes the HTML first and sprinkles ngModel on inputs; Angular quietly builds the control panel behind the scenes.

This chapter builds one realistic sign-up form — name, email, password + confirmation, a business-only company field, a disabled plan field and a list of phone numbers — three times, once per approach. Every behaviour quoted below comes from Vitest specs that type into the real inputs (dispatching input and blur events) in an Angular 22.2 project.

imports.ts
// Signal Forms — one standalone directive
import { FormField, form, required, email } from '@angular/forms/signals';
@Component({ imports: [FormField], … })

// Reactive Forms — a module of directives
import { ReactiveFormsModule, NonNullableFormBuilder, Validators } from '@angular/forms';
@Component({ imports: [ReactiveFormsModule], … })

// Template-driven Forms — a different module of directives
import { FormsModule, NgForm } from '@angular/forms';
@Component({ imports: [FormsModule], … })
What’s happening
  1. Each approach is a set of directives that the template compiler needs to know about, so each must be listed in the component's imports (see The modern import model). Forget it and the build fails with NG8002: Can't bind to 'formField' / 'formGroup' / 'ngModel' since it isn't a known property of 'input' — I verified all three.
  2. Signal Forms ship from a separate entry point, @angular/forms/signals. form() and the rule functions (required, email, …) are plain functions you call in the class; only FormField goes in imports, because it's the only thing the template uses.
  3. ReactiveFormsModule and FormsModule are both NgModules that bundle many directives (FormGroupDirective, FormControlName, … or NgModel, NgForm, …) plus the shared validator directives and value accessors. They are not interchangeable: [formGroup] lives only in ReactiveFormsModule, ngModel only in FormsModule.
  4. The table's last column is the whole story of each approach in one attribute: Signal Forms bind a field of a data model, Reactive Forms bind a control object by name, Template-driven Forms bind a property with banana-in-a-box two-way binding (see Two-way binding).

#Signal Forms: creating a form from a model

Signal Forms start from a simple idea: your data is the form. You already have (or want) a plain TypeScript object describing what the user is filling in. Put it in a signal(), hand that signal to form(), and you get back a FieldTree — an object with the same shape as your data, where every property gives you access to that field's live state (value, errors, touched, dirty, disabled…). There is no second copy of the data: the form reads from and writes to your signal.

The model is the source of truth; the form is a lens over it. That's the main contrast with Reactive Forms, where the FormGroup owns the value and you read it out with .value.

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

export interface SignupModel {
  name: string;
  email: string;
  password: string;
  confirmPassword: string;
  accountType: 'personal' | 'business';
  company: string;
  plan: string;
  phones: { number: string }[];
}

@Component({ selector: 'app-signal-signup', imports: [FormField], template: `…` })
export class SignalSignup {
  readonly model = signal<SignupModel>({
    name: '',
    email: '',
    password: '',
    confirmPassword: '',
    accountType: 'personal',
    company: '',
    plan: 'free',
    phones: [],
  });

  readonly signupForm = form(this.model, (s) => {
    required(s.name, { message: 'Name is required' });
    minLength(s.name, 2, { message: 'At least 2 characters' });
    required(s.email, { message: 'Email is required' });
    email(s.email, { message: 'Enter a valid email' });
  });
}
What’s happening
  1. signal<SignupModel>({...}) creates an ordinary writable signal holding the initial data. Nothing form-specific yet — you could pass this signal around, set() it from an HTTP response, read it in a computed().
  2. form(this.model, (s) => {...}) builds the FieldTree. Its shape mirrors SignupModel: signupForm.name, signupForm.email, signupForm.phones… each is itself a FieldTree for that property (typed FieldTree<string>, FieldTree<{ number: string }[]>, etc.).
  3. The second argument is the schema function. It runs once, at creation, with s — a path object of the same shape. required(s.name, …) doesn't validate anything right now; it registers a rule against the name path. The rules are evaluated reactively later, whenever the value they depend on changes.
  4. form() needs an injection context (it uses DI internally), so call it in a field initialiser or constructor. Calling it from a plain function throws NG0203: The Injector token injection failed. inject() function must be called from an injection context… — verified. If you really need to create one later, pass { injector } as the options argument.
  5. Because there's no copy, this.model() and this.signupForm().value() are always the same data. Change one and you've changed the other (next topics prove it).
AdvancedA FieldTree is callable

The one syntax rule to burn in: a FieldTree is callable. signupForm.name is the field (you navigate with dots); signupForm.name() is its state (you call it to read signals). So the value is signupForm.name().value() — two calls: one to get the state, one to read the value signal.

AdvancedCompile error: .value() on a field

#Binding fields to inputs

Once you have a field, you connect it to an <input> with the FormField directive, whose selector is [formField]. In Angular 22 the import is FormField from @angular/forms/signals (earlier experimental releases used other names — the hidden() doc comment in 22's own .d.ts still shows an old [control] binding, so don't trust older blog posts).

[formField] is a full two-way binding: it pushes the field's value into the element, listens for user input and writes it back into your model signal, and copies the field's rules onto the element as attributes.

signal/signup.ts (template excerpt)
<label>Name <input id="name" [formField]="signupForm.name" /></label>
<label>Email <input id="email" type="email" [formField]="signupForm.email" /></label>

<select id="accountType" [formField]="signupForm.accountType">
  <option value="personal">Personal</option>
  <option value="business">Business</option>
</select>

<label>Plan <input id="plan" [formField]="signupForm.plan" /></label>
What’s happening
  1. [formField]="signupForm.name" passes the field tree itself (no parentheses). The directive calls it internally to subscribe to the state. Passing signupForm.name() instead is a type error: TS2322: Type 'FieldState<string, string>' is not assignable to type 'Field<any, string | number>'.
  2. Model → DOM: on first render the input shows ''. After this.model.update(m => ({ ...m, email: 'a@b.co' })) and a render, the email input's .value is 'a@b.co' (verified). Writing through the field — signupForm.name().value.set('Via field') — updates both the input and this.model().name.
  3. DOM → model: in the spec, setting input.value = 'Rohit' and dispatching an input event makes this.model().name === 'Rohit' synchronously — I logged the model before await fixture.whenStable() and it had already changed. A <select> also listens to input (browsers fire it alongside change).
  4. Rules become attributes: the rendered name input is <input id="name" minlength="2" name="…" required=""> and the plan input gets disabled="" — [formField] reflects required, minLength, min, disabled and an auto-generated name for you. A number field on <input type="number"> gets min="18" from min(p.age, 18).
  5. Types follow the element: <input type="number"> writes a real number into a number field (typeof model().age === 'number' after typing 17), a checkbox writes a boolean, and binding a string field to a checkbox fails to compile with TS2322: Type 'string' is not assignable to type 'boolean'. Strict templates check every binding.
AdvancedCustom controls with FormValueControl

For your own components (a date picker, a star rating) you implement FormValueControl<T> — the only required member is readonly value = model<T>() — or FormCheckboxControl with a checked model, and [formField] will bind to them as if they were native inputs. Existing ControlValueAccessor components also work, for compatibility.

#Reading the form value and fields

From my notes, "how to read the form model" and "accessing individual fields" come down to three equivalent views of the same data, plus per-field state:

signal/signup.spec.ts (excerpt)
await type(fixture, '#name', 'Rohit');

c.model().name;                 // 'Rohit'  — your signal
c.signupForm.name().value();    // 'Rohit'  — the field's value signal
c.signupForm().value().name;    // 'Rohit'  — the root field's value (the whole object)

c.signupForm.name().value.set('Via field');
c.model().name;                 // 'Via field'
c.signupForm.name().dirty();    // false — programmatic writes aren't "user changes"
What’s happening
  1. type() is a tiny helper in my specs: set input.value, dispatch new Event('input'), await fixture.whenStable(). That's exactly what a keystroke does as far as Angular is concerned.
  2. All three reads return 'Rohit' because they are three windows onto one signal. signupForm() is the root field's state, so signupForm().value() is the whole model object, the same reference as this.model().
  3. signupForm.name().value is a WritableSignal<string>, so .set() and .update() work on it. Writing a leaf field produces a new model object with that property replaced — this.model() reflects it immediately.
  4. dirty() stays false after both value.set() and model.update() — verified. Dirty means "the user changed this through a bound control", not "the value differs from the initial one". That's what lets you load server data into the model without the form looking edited.
  5. Because every piece is a signal, you can derive from the form like any other state: readonly canSubmit = computed(() => this.signupForm().valid() && !this.signupForm().submitting()). No valueChanges subscription needed (compare valueChanges).
AdvancedNavigating array fields

Arrays are navigable too: signupForm.phones has a length and can be iterated with @for, and signupForm.phones[0].number is the first item's number field (nothing checks that index 0 exists, just like a plain array).

#Validation in Signal Forms

Validation rules live in the schema function — the second argument to form(). Each rule function takes a path (s.email) and attaches logic to it. The rules are reactive: Angular re-evaluates a rule only when a signal it reads changes, and the resulting errors appear on that field's errors() signal.

From my notes, "the flow is basically": model signal → schema rules → field-state signals (errors, valid, invalid…) → template reads them. Nothing is pushed manually and nothing needs updateValueAndValidity().

signal/signup.ts (schema)
readonly signupForm = form(this.model, (s) => {
  required(s.name, { message: 'Name is required' });
  minLength(s.name, 2, { message: 'At least 2 characters' });
  required(s.email, { message: 'Email is required' });
  email(s.email, { message: 'Enter a valid email' });
  required(s.password);
  minLength(s.password, 8);
  validate(s.confirmPassword, ({ value, valueOf }) =>
    value() === valueOf(s.password)
      ? undefined
      : { kind: 'mismatch', message: 'Passwords do not match' },
  );
});
What’s happening
  1. Built-in rules in 22: required, email, minLength, maxLength, min, max, pattern, minDate, maxDate. Each accepts an optional config: message (a string or a function of the field context), or error (a custom error object), plus when (see Conditional validation).
  2. Errors are objects with a kind and an optional message. For the empty form, signupForm.name().errors() is [{ kind: 'required', message: 'Name is required' }].
  3. Built-ins have no default message. required(s.password) with no config gives [{ kind: 'required', message: undefined }] — verified. Either pass message, or map kind to text in the template. After setting the password to 'abc', its error is { kind: 'minLength', minLength: 8 }, and signupForm.password().getError('minLength')?.minLength is 8 (typed — getError narrows by kind).
  4. minLength ignores empty values (the name field shows only required when empty, then At least 2 characters after typing R). Required-ness is a separate rule, which is why you usually pair them.
  5. validate() is the custom-rule hook. The callback receives a field context: value() is this field's value, valueOf(path) reads any other field. Return undefined (or null) for valid, or one or more { kind, message? } objects. Because the rule reads valueOf(s.password), it re-runs when the password changes: typing secret123 / secret12 shows Passwords do not match; changing the password to secret12 clears it and confirmPassword().valid() becomes true — verified.
  6. signupForm().errorSummary() collects errors from the whole tree. With name and email empty, password abc and confirm empty, the kinds were ['required', 'required', 'minLength', 'mismatch']. The root's own errors() was [] — errors() is a field's own errors only.
AdvancedShowing errors after blur

Showing the errors is just reading signals. The usual rule — only complain after the user has left the field — is touched() && invalid():

signal/signup.ts (template excerpt)
<input id="name" [formField]="signupForm.name" />
@if (signupForm.name().touched() && signupForm.name().invalid()) {
  @for (err of signupForm.name().errors(); track err.kind) {
    <p class="error">{{ err.message }}</p>
  }
}
What’s happening
  1. Before any interaction, invalid() is true (the name is empty) but touched() is false, so nothing renders — no red errors on a fresh form.
  2. Blur without typing → touched() flips to true, and the paragraph Name is required appears (verified).
  3. Type R → the required error goes away and minLength produces At least 2 characters. The @for renders one paragraph per error, tracked by kind.
  4. Type Rohit → errors() is [], invalid() is false, the block disappears. Each step is the template reacting to signals; no manual change detection.
AdvancedAsync rules: validateAsync and validateHttp

Async rules exist too: validateAsync(path, { params, factory, onSuccess, onError }) builds a resource() for the check, and validateHttp(path, { request, onSuccess, onError }) does it with httpResource. Per the 22 .d.ts, "async validation for a field only runs once all synchronous validation is passing", and while it runs the field is pending(). (I read these signatures but didn't exercise them in a test; the Reactive version of an async validator is tested in Custom validators.) For cross-field errors that should land on a different field, validateTree() lets a rule return errors with a fieldTree target.

AdvancedPending state: valid vs invalid

#Field state: dirty, touched, valid

Every field (and every group, and the root) has a set of state signals. The two my notes kept mixing up are touched and dirty, and the clean way to remember them: touched is about focus, dirty is about value.

  • touched() — the user has focused and then left the control (blur). Typing alone doesn't touch.
  • dirty() — the user has changed the value through the control. Focus/blur alone doesn't dirty. Programmatic writes don't dirty either.
  • valid() / invalid() / pending() — validation outcome (see the note above about pending).
  • disabled(), readonly(), hidden() — availability, set by rules.
  • submitting() — true while a submit() action is running.
signal/signup.spec.ts (touched vs dirty, condensed)
const n = c.signupForm.name;
// initial:            touched false, dirty false, valid false, invalid true
focus('#name');     // touched false, dirty false
blur('#name');      // touched true,  dirty false  → "Name is required" shows

const e = c.signupForm.email;
type('#email', 'x');  // touched false, dirty true   → no error shown yet
blur('#email');       // touched true,  dirty true   → "Enter a valid email"
type('#email', '');   // dirty stays true            → "Email is required"

c.signupForm().touched();  // true  — some child is touched
c.signupForm().dirty();    // true  — some child is dirty
c.signupForm().valid();    // false — some child is invalid
What’s happening
  1. Initially the name field is invalid (empty but required) and neither touched nor dirty. A pristine invalid form is normal — that's why error display is gated on touched().
  2. A focus event changes nothing. Only blur marks the field touched — the FormField directive relays the blur into the field state. Now touched() && invalid() is true and the error renders.
  3. On the email field, typing x makes it dirty but not touched: the user is still in the field, so the template correctly holds back Enter a valid email until they leave. That is exactly why "touched" is the better trigger for error messages than "dirty".
  4. Clearing the value doesn't make it pristine again: dirty means "has been changed", not "differs from the start". The error switches from the email-format message to Email is required.
  5. Parent state is aggregated: the root is touched/dirty if any descendant is, and valid only if every (non-hidden, non-disabled) descendant is valid. This is my notes' "form-level state".
  6. signupForm().reset() clears touched and dirty on the whole tree but keeps the value — after typing Rohit, blur, then reset(), the name field was untouched, pristine, and model().name was still 'Rohit' (verified). To clear values too, pass them: reset(value), or model.set(...) yourself. You can also force state with markAsTouched() and markAsDirty().
AdvancedStyling difference: no ng-* state classes
Touched or dirty?

A user tabs into the email field, types rohit@, and while the cursor is still in the field your template checks signupForm.email().touched() and signupForm.email().dirty(). Then they press Tab. What are the two values before and after?

Show answer

Before Tab: touched() is false, dirty() is true. After Tab: both true.

What’s happening
  1. Focusing the field does nothing to either flag.
  2. Each keystroke fires an input event; [formField] writes the value into the model and marks the field dirty — the user changed it.
  3. The field isn't touched until focus leaves it, so mid-typing touched() is still false. A touched() && invalid() error message correctly stays hidden while they're typing rohit@.
  4. Tab fires blur, the directive marks the field touched, and now the email error (rohit@ isn't a valid address) appears.

#Conditional validation

Real forms have rules that only apply sometimes: "company is required for business accounts", "show the VAT field only for EU addresses". Signal Forms handle this declaratively — you describe when a rule applies with a reactive function, and Angular turns the rule on and off as the data changes.

There are three tools, from narrowest to broadest:

  • The when option on any built-in validator: required(s.company, { when: ({ valueOf }) => valueOf(s.accountType) === 'business' }).
  • applyWhen(path, condition, schemaFn) — apply a whole block of rules only while condition is true. (applyWhenValue(path, predicate, schemaFn) is the variant whose predicate receives the value directly, and can narrow its type.)
  • Availability rules: hidden(path, { when }), disabled(path, { when }), readonly(path, { when }). A hidden, disabled or readonly field doesn't contribute to its parent's validity, touched or dirty state.
signal/signup.ts (schema, continued)
hidden(s.company, { when: ({ valueOf }) => valueOf(s.accountType) !== 'business' });

applyWhen(s, ({ value }) => value().accountType === 'business', (biz) => {
  required(biz.company, { message: 'Company is required for business accounts' });
});

disabled(s.plan, { when: () => 'Plan is chosen after sign-up' });
AdvancedHidden and required company field in action
signal/signup.ts (template excerpt)
@if (!signupForm.company().hidden()) {
  <label>Company <input id="company" [formField]="signupForm.company" /></label>
}
What’s happening
  1. With accountType: 'personal': company().hidden() is true, the @if removes the input, company().errors() is [] and company().required() is false. A complete personal sign-up with an empty company is valid() — verified.
  2. The user picks Business in the select: the model becomes accountType: 'business'. Both reactive conditions read accountType, so they re-evaluate: hidden() becomes false (the input appears) and the applyWhen block switches on — company().errors() is now ['Company is required for business accounts'] and required() is true. The whole form's errorSummary() kinds became ['required'].
  3. applyWhen(s, …) is applied at the root path s, so its condition gets the whole model (value().accountType), and the inner schema function receives the same shape as biz — that's why it can reach biz.company. You could also scope it to a sub-object.
  4. hidden() doesn't remove anything from the DOM by itself — it only changes state and validity. Guarding the input with @if (!field().hidden()) is your job (the .d.ts says so too).
  5. disabled(s.plan, { when: () => 'reason' }) returning a string means "disabled, and here's why": plan().disabled() is true, plan().disabledReasons() is [{ fieldTree: …, message: 'Plan is chosen after sign-up' }], and the input got the disabled attribute. Return true/false if you don't need a reason. (Passing the function directly, disabled(s.plan, () => …), still works but is deprecated in 22 in favour of { when }.)
  6. If you changed applyWhen to the when option — required(s.company, { when: ({ valueOf }) => valueOf(s.accountType) === 'business' }) — the behaviour is the same for one rule (verified: required() false → true and a required error after switching). applyWhen is worth it when several rules share a condition.
AdvancedDisabled fields vs Reactive Forms

#A complete Signal Forms example

Putting it together: the full sign-up form with arrays and submission. Submission in Signal Forms is the submit() function: submit(form, action) marks every field touched (so all errors show), runs your async action only if the form is valid, sets submitting() while it runs, and maps any errors the action returns back onto fields — perfect for "email already registered" from the server.

signal/signup.ts
import { Component, signal } from '@angular/core';
import {
  FormField, form, required, email, minLength, validate,
  applyWhen, applyEach, disabled, hidden, submit,
} from '@angular/forms/signals';

@Component({
  selector: 'app-signal-signup',
  imports: [FormField],
  template: `
    <form (submit)="onSubmit($event)" novalidate>
      <!-- name, email, password, confirm, accountType, company, plan as above -->

      @for (phone of signupForm.phones; track phone) {
        <input class="phone" [formField]="phone.number" />
        <button type="button" (click)="removePhone($index)">x</button>
      }
      <button type="button" (click)="addPhone()">Add phone</button>

      <button type="submit" [disabled]="signupForm().submitting()">Sign up</button>
    </form>
  `,
})
export class SignalSignup {
  readonly model = signal<SignupModel>({ /* as before, phones: [] */ });

  readonly signupForm = form(this.model, (s) => {
    // …all the rules from the previous topics…
    applyEach(s.phones, (phone) => {
      required(phone.number, { message: 'Enter a number or remove the row' });
    });
  });

  readonly submitted: SignupModel[] = [];

  addPhone() {
    this.model.update((m) => ({ ...m, phones: [...m.phones, { number: '' }] }));
  }

  removePhone(i: number) {
    this.model.update((m) => ({ ...m, phones: m.phones.filter((_, idx) => idx !== i) }));
  }

  async onSubmit(event: Event) {
    event.preventDefault();
    await submit(this.signupForm, async (f) => {
      const value = f().value();
      if (value.email === 'taken@example.com') {
        return { kind: 'server', message: 'Email already registered', fieldTree: f.email };
      }
      this.submitted.push(value);
      return undefined;
    });
  }
}
What’s happening
  1. Arrays are just arrays in the model. addPhone() replaces the model with a new phones array (immutably — mutating in place wouldn't notify the signal). signupForm.phones grows to match, @for renders a new input, and applyEach applies the required rule to every item, current and future. After adding two rows and typing 555-0101 into the second, the model was [{"number":""},{"number":"555-0101"}], phones().valid() was false and the first row's error was Enter a number or remove the row.
  2. removePhone(0) filters the array; the model became [{"number":"555-0101"}] and the remaining input showed 555-0101. track phone tracks the field tree for each item: for object items, Signal Forms keeps the same FieldTree (and its touched/dirty state) attached to the same object even when its index shifts — I checked phones[0] === oldPhones[1] after removing the first item: true. For arrays of primitives (string[]) identity is positional (false in the same test), so prefer arrays of objects.
  3. Clicking Sign up on the empty form: submit() marks the whole tree touched (name().touched() and email().touched() became true), so Name is required and Email is required appear at once. The form is invalid, so the action never runs — submitted.length stayed 0, and await submit(...) resolves to false.
  4. Fill the form validly with taken@example.com, submit: the action runs and returns a server error targeted at f.email. submit() attaches it to that field: email().errors() kinds were ['server'] and the page showed Email already registered. Errors returned from an action behave like validation errors, so the form is invalid until the user edits.
  5. Typing a new email cleared the server error (errors() became []) — it belonged to the old value. Submitting again ran the action, which pushed the full model value; submit() resolved to true.
  6. While the action's promise is pending, signupForm().submitting() is true, which disables the button and blocks double submits — submit() itself also refuses to start a second concurrent submission.
AdvancedDeclaring submission with FormRoot

You can also declare submission once, on the form, and let the FormRoot directive (selector form[formRoot]) wire up the <form> element:

signal/extra.spec.ts (FormRoot)
@Component({
  imports: [FormField, FormRoot],
  template: `
    <form [formRoot]="loginForm">
      <input id="user" [formField]="loginForm.user" />
      <button type="submit">Go</button>
    </form>
  `,
})
class RootLogin {
  readonly calls: string[] = [];
  readonly model = signal({ user: '' });
  readonly loginForm = form(this.model, (p) => { required(p.user); minLength(p.user, 3); }, {
    submission: {
      action: async (f) => { this.calls.push(f().value().user); return undefined; },
      onInvalid: () => this.calls.push('invalid'),
    },
  });
}
What’s happening
  1. form(model, schema, options) — the third argument's submission holds the same action you'd pass to submit(), plus an optional onInvalid callback.
  2. [formRoot] adds novalidate to the <form> (the rendered tag was <form novalidate="">), listens for the native submit event, prevents the page reload and calls submit() for you. No (submit) handler, no preventDefault().
  3. Clicking Go with an empty user called onInvalid (calls was ['invalid']) and marked the field touched. After typing rohit, clicking Go ran the action (['invalid', 'rohit']).
  4. Calling submit(this.loginForm) with no action from code uses the form's submission options too — it returned true and appended another 'rohit'.

#Signal Forms vs Reactive Forms

From my notes: "Reactive forms are also important — don't ignore!" Signal Forms are the future, but Reactive Forms are what most existing apps — including our Angular 16 techStatus project — are built on, and they're still fully supported in 22. You'll maintain them, migrate them, and be asked about them.

The core difference is who owns the data. In Signal Forms, your model signal owns it and the form derives state from it. In Reactive Forms, the FormGroup owns the value; your model is whatever you copy out of form.value at submit time.

Signal FormsReactive Forms
Source of truthYour signal() modelThe FormGroup / FormControl tree
Reading stateSignals: f.email().valid()Properties + observables: ctrl.valid, ctrl.valueChanges
RulesSchema functions: required(s.email), applyWhenValidator arrays: [Validators.required], setValidators() + updateValueAndValidity()
Conditional logicReactive when, recomputed automaticallyImperative: subscribe to valueChanges, toggle validators/enable/disable
Disabled fieldsStay in the modelDropped from .value, kept in getRawValue()
ArraysPlain arrays in the modelFormArray with push / removeAt
TypingFrom the model's typeTyped forms (Angular 14+), NonNullableFormBuilder
Status classesOpt in (NG_STATUS_CLASSES)ng-touched, ng-dirty… automatic
Fits best withSignals, zoneless, OnPush (22's defaults)RxJS pipelines, existing codebases, CVA libraries
What’s happening
  1. The "Conditional logic" row is the biggest day-to-day difference. In Reactive Forms, "company required for business" means subscribing to accountType.valueChanges, calling company.setValidators(...) or company.enable() and then updateValueAndValidity() — and remembering to unsubscribe. In Signal Forms it's one declarative applyWhen.
  2. The "Disabled fields" row is a classic bug source in Reactive Forms: a disabled control silently vanishes from .value, so the request body is missing a field. Signal Forms don't have the problem because there's no separate form value.
  3. Reactive Forms' strength is RxJS: valueChanges.pipe(debounceTime(300), switchMap(...)) is natural there (see switchMap). With Signal Forms you'd use debounce(s.email, 300) in the schema, or a resource() keyed on a field value.
  4. They interoperate: @angular/forms/signals/compat has SignalFormControl (a FormControl backed by Signal Forms rules, so it can live inside an existing FormGroup) and compatForm() (a signal form whose model can contain existing FormControls). That lets you migrate a big reactive form one piece at a time. (I read these in the .d.ts; I didn't build a migration.)

#FormControl and FormGroup

Reactive Forms model a form as a tree of control objects. A FormControl holds one value plus its state — value, status ('VALID', 'INVALID', 'PENDING' or 'DISABLED'), errors, touched, dirty — and exposes valueChanges / statusChanges observables. A FormGroup holds named child controls and aggregates them: its value is an object built from the children's values, it's invalid if any child is, touched if any child is. (FormArray does the same for an ordered list — see FormArray and nested forms.)

My notes: "Real forms have multiple controls" — that's the FormGroup. The template doesn't hold state at all; it just points directives at the objects.

reactive/minimal.ts (class excerpt)
readonly signupForm = new FormGroup({
  name: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(2)] }),
  email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }),
});
AdvancedBinding FormGroup controls in the template
reactive/signup.ts (template excerpt)
<form [formGroup]="signupForm" (ngSubmit)="onSubmit()">
  <input id="name" formControlName="name" />
  @if (signupForm.controls.name.touched && signupForm.controls.name.hasError('required')) {
    <p class="error">Name is required</p>
  }
  @if (signupForm.controls.name.touched && signupForm.controls.name.hasError('minlength')) {
    <p class="error">At least {{ signupForm.controls.name.getError('minlength').requiredLength }} characters</p>
  }
</form>
What’s happening
  1. new FormGroup({...}) creates the tree in the class, before the template exists. signupForm.controls.name is a FormControl<string>; the validators are plain functions that run on every value change.
  2. [formGroup]="signupForm" binds the <form> to the group object; formControlName="name" looks up the child called name in that group and connects it to the input via a value accessor. Initially the input's classes are ng-untouched ng-pristine ng-invalid, and name.status is 'INVALID'.
  3. Blur without typing: touched becomes true, dirty stays false, classes become ng-pristine ng-invalid ng-touched, and Name is required renders (verified).
  4. Type R: the input event sets the control's value to 'R' and marks it dirty (ng-invalid ng-touched ng-dirty); validators re-run and errors is { minlength: { requiredLength: 2, actualLength: 1 } }, so At least 2 characters renders. Type Rohit and both messages disappear.
  5. name.setValue('Programmatic') updates the input's .value after render — and does not mark the control dirty (verified with the password control: setValue('x') → dirty false). Same rule as Signal Forms: dirty is user-driven.
  6. Error keys are lowercase in Reactive Forms ('minlength', 'maxlength', 'required', 'email'), and the error payloads are objects like { requiredLength, actualLength }. In Signal Forms the kind is camelCase 'minLength'. Mixing them up is an easy typo that fails silently in a template hasError().
AdvancedOlder templates: string-based get()

Plain ngIf-era templates used *ngIf="signupForm.get('name')?.touched" — the string-based get() still works, but with typed forms signupForm.controls.name is better: it's checked by the compiler.

#formControl vs formControlName

From my notes: "Confusion between formControl and formControlName." The difference is how the directive finds the control:

  • [formControl]="search" — a property binding: you hand the directive the FormControl object itself. No parent needed. Use it for a lone control (a search box, a filter) that isn't part of a form.
  • formControlName="city" — a string: "find the child named city in my nearest parent [formGroup] (or formGroupName / formArrayName)". Use it inside forms.
reactive/typed.ts
@Component({
  selector: 'app-typed-demo',
  imports: [ReactiveFormsModule],
  template: `
    <!-- standalone control: [formControl] takes the control object itself -->
    <input id="search" [formControl]="search" />
    <p id="echo">{{ search.value }}</p>

    <!-- inside a group: formControlName looks the control up BY NAME in the parent [formGroup] -->
    <form [formGroup]="profile">
      <input id="city" formControlName="city" />
    </form>
  `,
})
export class TypedDemo {
  readonly search = new FormControl('', { nonNullable: true });
  readonly profile = new FormGroup({ city: new FormControl('Toronto') });
}
What’s happening
  1. [formControl]="search" evaluates the expression search — the control object — and binds it straight to the input. Typing angular made <p id="echo"> show angular.
  2. formControlName="city" has no brackets because the value is a literal string. At runtime the directive injects the parent FormGroupDirective (from [formGroup]) and calls the equivalent of profile.get('city'). Typing Ottawa made profile.value.city === 'Ottawa'.
  3. Use formControlName with no parent group and it throws at runtime: NG01050: formControlName must be used with a parent formGroup or formArray directive. You'll want to add a formGroup/formArray directive and pass it an existing FormGroup/FormArray instance (you can create one in your class). — verified.
  4. Inside a FormArray the "name" is the index, so you bind it: [formControlName]="i" (shown in FormArray and nested forms).
  5. [formControl] doesn't care where the object came from: if you bind a control that isn't a child of the group, the input works but its value is not part of the group's .value or validity. Inside a form, binding by name keeps the template and the group's structure in step.
Which one?

You have readonly form = new FormGroup({ address: new FormGroup({ street: new FormControl('') }) }). Write the template to bind street, using names only.

Show answer
HTML
<form [formGroup]="form">
  <div formGroupName="address">
    <input formControlName="street" />
  </div>
</form>
What’s happening
  1. [formGroup]="form" binds the root group object (a property binding, because you're passing an object).
  2. formGroupName="address" is the group equivalent of formControlName: it looks up address in the parent and becomes the parent for everything inside it.
  3. formControlName="street" now resolves against the address group, not the root. Writing formControlName="address.street" doesn't work — names are one level at a time.
  4. The alternative [formControl]="form.controls.address.controls.street" would also bind, but that's the standalone style and skips the group directives.

#FormBuilder and typed forms

new FormGroup({ name: new FormControl('', { nonNullable: true, validators: [...] }) }) gets noisy fast. From my notes: "FormBuilder is better than FormGroup" — FormBuilder is just a service with shorthand factories: fb.group({...}), fb.control(...), fb.array(...), where a field can be written as [initialValue, validators, asyncValidators].

Since Angular 14, Reactive Forms are typed: fb.group({ name: '' }) infers FormGroup<{ name: FormControl<…> }>. The catch is nullability: a normal FormControl can be reset(), and reset sets it to null — so its type is string | null. NonNullableFormBuilder makes every control reset to its initial value instead, so the type is plain string. That's the modern default my notes call "NonNullable for modern form types".

reactive/signup.ts
import { Component, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { emailAvailable, noSpaces, passwordsMatch } from './validators';

@Component({ selector: 'app-reactive-signup', imports: [ReactiveFormsModule], template: `…` })
export class ReactiveSignup {
  private readonly fb = inject(NonNullableFormBuilder);

  readonly signupForm = this.fb.group({
    name: ['', [Validators.required, Validators.minLength(2)]],
    email: this.fb.control('', {
      validators: [Validators.required, Validators.email, noSpaces],
      asyncValidators: [emailAvailable(['taken@example.com'])],
    }),
    passwords: this.fb.group(
      {
        password: ['', [Validators.required, Validators.minLength(8)]],
        confirmPassword: [''],
      },
      { validators: [passwordsMatch] },
    ),
    plan: [{ value: 'free', disabled: true }],
    phones: this.fb.array<string>([]),
  });
}
What’s happening
  1. inject(NonNullableFormBuilder) gets the builder (it's providedIn: 'root'). The older equivalent is inject(FormBuilder).nonNullable.
  2. name: ['', [Validators.required, Validators.minLength(2)]] is the array shorthand: initial value, then sync validators. The result is FormControl<string>.
  3. email: this.fb.control('', { validators, asyncValidators }) is the long form — needed here to add an async validator (see Custom validators).
  4. passwords: this.fb.group({...}, { validators: [passwordsMatch] }) nests a group and gives the group a validator, because "passwords match" depends on two children.
  5. plan: [{ value: 'free', disabled: true }] creates a control that starts disabled — the "boxed value" form. phones: this.fb.array<string>([]) is an empty typed FormArray<FormControl<string>>.
  6. The compiler now knows every path: signupForm.controls.passwords.controls.confirmPassword is a FormControl<string>.
AdvancedTyped form types checked at compile time

What the types actually are (each line checked with a type-level Equal<A, B> assertion that had to be true):

reactive/typed.ts
readonly loose = inject(FormBuilder).group({ name: '' });
readonly strict = inject(NonNullableFormBuilder).group({ name: '' });

loose.controls.name;      // FormControl<string | null>
strict.controls.name;     // FormControl<string>
strict.value;             // Partial<{ name: string }>
strict.getRawValue();     // { name: string }

strict.controls.name.setValue(5);     // ❌ TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
strict.get('nmae')!.setValue('x');    // ❌ TS2345: Argument of type '"x"' is not assignable to parameter of type 'never'.

new FormControl('start').reset();                        // value → null
new FormControl('start', { nonNullable: true }).reset(); // value → 'start'
What’s happening
  1. With the plain FormBuilder, name is FormControl<string | null> — every read needs a null check, which is why people gave up and used any. NonNullableFormBuilder gives FormControl<string>.
  2. strict.value is Partial<{ name: string }> — every key is optional, because a disabled child is left out of .value (next topics). getRawValue() includes disabled children, so it's the full { name: string }.
  3. Typed forms catch the classic mistakes at compile time: setting a number into a string control is TS2345, and a misspelt get('nmae') resolves to a control of type never, so setValue('x') is a TS2345 too. At runtime that get('nmae') returns null.
  4. The reset lines show why the types differ: the nullable control reset to null, the non-nullable one back to 'start' (verified: plain reset -> null | nonNullable reset -> start).
  5. My notes' "Controls…" point: prefer form.controls.x (typed, refactor-safe) over form.get('x') (string-based). For deep paths, form.controls.passwords.controls.password.

#Custom validators

A Reactive Forms validator is just a function: it receives an AbstractControl and returns null when valid or an error object (ValidationErrors, e.g. { noSpaces: true }) when not. Angular calls it on every value change and merges the error objects of all validators on that control into control.errors. There are three shapes you need to know — and they're the same three that come up in interviews.

reactive/validators.ts
import { AbstractControl, AsyncValidatorFn, ValidationErrors, ValidatorFn } from '@angular/forms';
import { map, timer } from 'rxjs';

// Sync validator for one control: no whitespace allowed.
export function noSpaces(control: AbstractControl<string>): ValidationErrors | null {
  return /\s/.test(control.value) ? { noSpaces: true } : null;
}

// Factory: a validator with a parameter.
export function forbidden(word: string): ValidatorFn {
  return (control) =>
    String(control.value).toLowerCase().includes(word) ? { forbidden: { word } } : null;
}

// Cross-field validator: goes on the GROUP, reads two children.
export const passwordsMatch: ValidatorFn = (group) => {
  const pw = group.get('password')?.value;
  const confirm = group.get('confirmPassword')?.value;
  return pw === confirm ? null : { passwordsMismatch: true };
};

// Async validator: pretend to ask the server whether the email is taken.
export function emailAvailable(taken: string[]): AsyncValidatorFn {
  return (control) =>
    timer(300).pipe(map(() => (taken.includes(control.value) ? { emailTaken: true } : null)));
}
What’s happening
  1. noSpaces is used directly (validators: [..., noSpaces]). Typing has space@example.com into the email gave errors = {"email":true,"noSpaces":true} — the errors of Validators.email and noSpaces merged into one object, so the template can check each with hasError('noSpaces').
  2. forbidden('admin') is a factory: you call it with a parameter and it returns the validator (a closure over word). Validators.minLength(2) is built the same way. Put the details in the error value ({ forbidden: { word } }) so the template can say which word.
  3. passwordsMatch is attached to the passwords group, because a validator only sees its own control and one field can't see its sibling. With secret123 / secret12, the group's errors were {"passwordsMismatch":true} while confirmPassword.errors was null — the error lives on the group, so the template checks signupForm.controls.passwords.hasError('passwordsMismatch') (gated on the confirm control being touched).
  4. emailAvailable returns an Observable (a Promise also works). Right after typing taken@example.com the control's status was 'PENDING' and the template's @if (email.pending) showed Checking…. After 300 ms the observable emitted, status became 'INVALID' and errors was {"emailTaken":true}.
  5. Async validators only run when the sync validators pass. For has space@example.com the status went straight to 'INVALID', never 'PENDING' — no pointless server call for an obviously bad value. And a new value cancels the check still in flight, much like switchMap: a control with a 50 ms async validator, set to 'a', 'ab', 'abc' in a row, subscribed 4 times (the initial '' plus three) and unsubscribed 4 times, and only the last result counted.
  6. If you changed passwordsMatch to sit on confirmPassword instead, it would only re-run when confirm changes — editing the password later wouldn't update the error. Group-level validators re-run whenever any child changes, which is why they're the right place for cross-field rules.
AdvancedSignal Forms validator equivalents

Signal Forms' equivalents, for comparison: validate(path, ctx => …) for sync and cross-field rules (it can read siblings with valueOf, so the error can live on the confirm field itself), and validateAsync / validateHttp for async rules (Validation in Signal Forms).

#setValue vs patchValue

Both write values into a FormGroup (or FormArray). The difference is strictness:

  • setValue(value) — you must supply exactly the group's shape: every control, no extras. It's an assertion that you're replacing the whole value.
  • patchValue(partial) — update whatever keys you supply; everything else stays; unknown keys are silently ignored.
reactive/signup.spec.ts (setValue vs patchValue)
const pw = form.controls.passwords;

pw.patchValue({ password: 'secret123' });
pw.value;   // { password: 'secret123', confirmPassword: '' }

pw.setValue({ password: 'secret123' });
// throws at runtime: NG01002: Must supply a value for form control with name: 'confirmPassword'.

pw.setValue({ password: 'a', confirmPassword: 'a', extra: 1 });
// throws at runtime: NG01001: Cannot find form control with name: 'extra'

pw.setValue({ password: 'a', confirmPassword: 'b' });
pw.value;   // { password: 'a', confirmPassword: 'b' }
What’s happening
  1. patchValue({ password }) walks the keys you passed and calls setValue on each matching child. confirmPassword isn't mentioned, so it keeps ''.
  2. setValue({ password }) first checks that every control in the group has a value in the argument. confirmPassword is missing, so it throws NG01002: Must supply a value for form control with name: 'confirmPassword'. (the real message, plus a link to https://v22.angular.dev/errors/NG01002). Nothing is written.
  3. The extra-key version throws NG01001: Cannot find form control with name: 'extra' — setValue is strict in both directions.
  4. With typed forms the compiler catches both mistakes first (I needed // @ts-expect-error to even run those lines). patchValue({ nope: 1 }) on the root is also a type error, but at runtime it's silently ignored — the value was unchanged.
  5. When to use which: patchValue for loading a partial server response or updating a few fields; setValue when you want a crash if the shape drifts (e.g. the API added a field you forgot to map). Template-driven ngForm.setValue({ name: 'A' }) throws the same NG01002, for 'email' — it's the same FormGroup underneath.
AdvancedSignal Forms: set vs update

In Signal Forms the same two ideas are just signal methods: model.set(full) replaces everything (TypeScript enforces the full shape), and model.update(m => ({ ...m, name: 'x' })) or signupForm.name().value.set('x') patches.

#valueChanges

Every control, group and array exposes valueChanges, an Observable that emits the new value each time it changes — from user input or from code. statusChanges emits 'VALID'/'INVALID'/'PENDING'/'DISABLED' the same way. This is where Reactive Forms meet RxJS: debounce a search box, cross-update fields, autosave a draft.

reactive/signup.spec.ts (valueChanges)
const seen: unknown[] = [];
form.controls.name.valueChanges.subscribe((v) => seen.push(v));
const group: unknown[] = [];
form.valueChanges.subscribe((v) => group.push(v.name));

await type(f, '#name', 'R');
await type(f, '#name', 'Ro');
form.controls.name.setValue('Rohit');
form.controls.name.setValue('Silent', { emitEvent: false });

seen;                       // ['R', 'Ro', 'Rohit']
group;                      // ['R', 'Ro', 'Rohit']
form.controls.name.value;   // 'Silent'
What’s happening
  1. Two subscriptions: one on the name control, one on the whole group. valueChanges is hot and synchronous — it doesn't replay the current value to a new subscriber (unlike a BehaviorSubject), and it emits inside the same call stack as the change.
  2. Typing R sets the control value; the control emits 'R', then the change bubbles up and the group emits its new value object (group records its name). Same for 'Ro'.
  3. setValue('Rohit') from code emits too — valueChanges doesn't distinguish user from program. That's how you accidentally build infinite loops when two fields update each other.
  4. setValue('Silent', { emitEvent: false }) changes the value without emitting valueChanges or statusChanges on this control or its ancestors — the tool for breaking those loops. The value is 'Silent' but neither array recorded it.
  5. A statusChanges subscription followed by setValue('') recorded ['INVALID'].
AdvancedTypeahead with valueChanges

The typeahead pattern is this.search.valueChanges.pipe(debounceTime(300), distinctUntilChanged(), switchMap(q => this.api.search(q))) (see debounceTime and distinctUntilChanged). Subscriptions you make yourself need cleanup — takeUntilDestroyed() (takeUntilDestroyed) — or convert to a signal with toSignal(control.valueChanges, { initialValue: control.value }) and let Angular manage it. Angular 18+ also has a unified control.events observable (value, status, touched and pristine change events, plus submit/reset at the form level).

AdvancedEdge case: no initial emission

#FormArray and nested forms

A FormArray is a FormGroup whose children are indexed instead of named — for "add another phone number" lists whose length is decided at runtime. It aggregates exactly like a group: its value is an array of child values, it's invalid if any child is. Nested forms are just groups inside groups (passwords above) or groups inside arrays (a list of addresses), and the template mirrors the nesting with formGroupName / formArrayName.

reactive/signup.ts (FormArray)
// in the class
phones: this.fb.array<string>([]),

addPhone() {
  this.signupForm.controls.phones.push(this.fb.control('', Validators.required));
}

removePhone(i: number) {
  this.signupForm.controls.phones.removeAt(i);
}
AdvancedNested groups and FormArray in the template
reactive/signup.ts (template excerpt)
<div formGroupName="passwords">
  <input id="password" type="password" formControlName="password" />
  <input id="confirm" type="password" formControlName="confirmPassword" />
</div>

<div formArrayName="phones">
  @for (phone of signupForm.controls.phones.controls; track phone; let i = $index) {
    <input class="phone" [formControlName]="i" />
    <button type="button" (click)="removePhone(i)">x</button>
  }
</div>
<button type="button" (click)="addPhone()">Add phone</button>
What’s happening
  1. formGroupName="passwords" makes the passwords group the parent for the two inputs inside, so formControlName="password" resolves to signupForm.controls.passwords.controls.password.
  2. formArrayName="phones" does the same for the array. @for iterates the array's controls (not its values), and [formControlName]="i" binds each input to the child at index i — bracketed, because the name is a number expression.
  3. Clicking Add phone twice pushes two FormControl<string>s with Validators.required. After typing 555-0101 into the second, phones.value was ["","555-0101"], length 2, and phones.valid false (the first is empty and required).
  4. removePhone(0) calls removeAt(0): the array re-indexes, the value became ["555-0101"], and the one remaining input showed 555-0101.
  5. track phone tracks the control object, so after removeAt(0) Angular moves the existing DOM row for the second control rather than reusing the first row. With track $index the first row's DOM would be kept and rebound — harmless for a bare input, but a bug for rows with their own state (see Why not track $index).
  6. Angular 22 also has a top-level [formArray] directive (the FormArrayDirective, selector [formArray]), for when the root of a form is an array rather than a group.
AdvancedArrays in Signal Forms with applyEach

In Signal Forms there's no special array class: phones is an ordinary array in the model, items are added and removed with model.update, and applyEach(s.phones, item => …) applies rules to every item (A complete Signal Forms example).

#Submitting, value and getRawValue

Submitting a Reactive form is: listen to (ngSubmit), check validity, show errors if invalid, otherwise read the value and send it. The subtle part is which value: form.value leaves out disabled controls, form.getRawValue() includes everything.

reactive/signup.ts (submit)
onSubmit() {
  if (this.signupForm.invalid) {
    this.signupForm.markAllAsTouched();
    return;
  }
  this.submitted.push(this.signupForm.getRawValue());
}
AdvancedSpec: value vs getRawValue on submit
reactive/signup.spec.ts (value vs getRawValue)
form.value;
// {"name":"","email":"","passwords":{"password":"","confirmPassword":""},"phones":[]}
form.getRawValue();
// {"name":"","email":"","passwords":{"password":"","confirmPassword":""},"plan":"free","phones":[]}

form.controls.plan.status;  // 'DISABLED'
form.controls.plan.valid;   // false — disabled is neither valid nor invalid
form.controls.plan.enable();
form.value;                 // now includes "plan":"free"
What’s happening
  1. (ngSubmit) comes from FormGroupDirective: it listens to the native submit event, calls preventDefault() (no page reload) and sets the directive's submitted flag. Pressing Enter in an input also submits.
  2. On the empty form, invalid is true, so markAllAsTouched() marks every control touched. Every touched && hasError(...) message appears at once — Name is required showed after one click, and submitted stayed empty. Without markAllAsTouched(), an untouched invalid form would refuse to submit with no visible reason.
  3. Filled in validly (and after the async email check finished), status was 'VALID' and getRawValue() was pushed: {"name":"Rohit","email":"rohit@example.com","passwords":{"password":"secret123","confirmPassword":"secret123"},"plan":"free","phones":[]}.
  4. form.value has no plan key because plan is disabled. That's deliberate (disabled means "not part of what the user is submitting") but it bites when the backend expects the field — hence getRawValue(), and hence .value being typed as Partial<…>.
  5. A disabled control's status is 'DISABLED', so valid and invalid are both false, and it's ignored by the group's validity. After enable() it reappears in .value.
  6. form.reset() with a NonNullableFormBuilder form put every control back to its initial value (getRawValue() matched the starting object, plan still 'free') and cleared touched/dirty. With nullable controls it would have set them to null.
AdvancedPitfall: template-bound disabled state
value or getRawValue?

const g = fb.group({ id: [{ value: 42, disabled: true }], title: ['Draft'] }). What do g.value and g.getRawValue() return, and what are their TypeScript types?

Show answer

g.value is { title: 'Draft' }, typed Partial<{ id: number; title: string }>. g.getRawValue() is { id: 42, title: 'Draft' }, typed { id: number; title: string }.

What’s happening
  1. id is created disabled, so its status is 'DISABLED'.
  2. A group's .value is built only from enabled children, so id is skipped: { title: 'Draft' }.
  3. Because any child might be disabled at runtime, the compiler can't promise any key exists in .value, so typed forms give it Partial<…>.
  4. getRawValue() ignores disabled state and includes every child, so its type is the full object. For a PUT that must send the id back, use getRawValue().

#Template-driven forms with ngModel

From my notes: "Template driven forms (NgModel) ???? !!!!! Revise this please." So, slowly.

The idea: in template-driven forms you don't create any FormControl yourself. You write normal HTML and put ngModel on each input. The NgModel directive creates a FormControl behind the scenes for that input, and the NgForm directive — which Angular attaches to every <form> automatically — creates a FormGroup and registers each control in it under the input's name attribute. So the same control tree as Reactive Forms exists; Angular just builds it from your template instead of your class.

Mental model ("model goes like this", from my notes): there are two copies of the data. Your component model (model.name) and the form's control value (signupForm.value.name). [(ngModel)] keeps them in sync: property binding pushes your model into the control and the input, the (ngModelChange) event pushes user input back into your model.

The critical import: FormsModule

From my notes, "very very very important!!!!": template-driven forms need FormsModule in the component's imports. Everything — ngModel, ngForm, ngModelGroup, the required/minlength/email validator directives, the value accessors that read checkboxes and number inputs — is declared in FormsModule.

template/signup.ts
import { Component } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';

interface Signup {
  name: string;
  email: string;
  password: string;
  newsletter: boolean;
  plan: string;
}

@Component({
  selector: 'app-template-signup',
  imports: [FormsModule],
  template: `
    <form #signupForm="ngForm" (ngSubmit)="onSubmit(signupForm)">
      <input id="name" name="name" [(ngModel)]="model.name" #nameCtrl="ngModel" required minlength="2" />
      <!-- … -->
    </form>
  `,
})
export class TemplateSignup {
  model: Signup = { name: '', email: '', password: '', newsletter: true, plan: 'free' };
}
What’s happening
  1. imports: [FormsModule] makes the NgModel, NgForm and validator directives available to this component's template. Standalone components don't inherit imports from anywhere — each component that uses ngModel imports FormsModule itself (in the NgModule days you imported it once into the AppModule or a shared module — see NgModules: the old way).
  2. Without it, the build fails: NG8002: Can't bind to 'ngModel' since it isn't a known property of 'input'. and, for #signupForm="ngForm", NG8003: No directive found with exportAs 'ngForm'. — both verified with ng build. The first error is the template compiler saying "no directive has an ngModel input, and <input> has no DOM property of that name".
  3. FormsModule also changes every <form> in the template: NgForm's selector is form:not([ngNoForm]):not([formGroup])…, so it attaches automatically. It adds novalidate (turning off the browser's own validation bubbles) and intercepts submit — dispatching a cancelable submit event on a plain <form> in a FormsModule component came back defaultPrevented: true. Add ngNoForm to opt a form out.
  4. ReactiveFormsModule does not provide ngModel, and FormsModule doesn't provide [formGroup] — importing the wrong one gives the same NG8002 for the other directive.
Advanced[(ngModel)] as property plus event binding

Basic ngModel

[(ngModel)] is banana-in-a-box two-way binding, so it's sugar for a property binding plus an event binding:

sugar
<input name="name" [(ngModel)]="model.name" />
what it means
<input name="name"
       [ngModel]="model.name"
       (ngModelChange)="model.name = $event" />
  • [ngModel]="model.name" — model → view: when model.name changes, NgModel writes it into its FormControl, which writes it into the input.
  • (ngModelChange)="model.name = $event" — view → model: when the user types, NgModel emits the new value and the assignment updates your property.
  • The name="name" attribute is the key under which this control is registered in the parent NgForm's FormGroup. It's mandatory inside a <form>.
template/signup.spec.ts (ngModel behaviour)
const f = TestBed.createComponent(TemplateSignup);
f.detectChanges();
ngForm.controls;   // {}  ← nothing registered yet!
ngForm.value;      // {}
await f.whenStable();
Object.keys(ngForm.controls);  // ['name', 'email', 'password', 'newsletter', 'plan']
ngForm.value;      // {"name":"","email":"","password":"","newsletter":true}

await type(f, '#name', 'Rohit');
c.model.name;            // 'Rohit'
ngForm.value.name;       // 'Rohit'
What’s happening
  1. The first detectChanges() creates the NgModel directives, but right after it the form has no controls and value {}. NgModel registers its control with the parent form asynchronously (in a resolved promise), to avoid "changed after checked" errors when the form tree changes during rendering.
  2. After await fixture.whenStable() the five controls exist, keyed by their name attributes, and ngForm.value mirrors the model — minus plan, which is disabled (more in the next topic).
  3. This is the famous template-driven trap: reading form.value or a control in ngOnInit (or straight after the first render) finds nothing. Reactive forms are synchronous — the controls exist before the template does — which is one reason they're preferred for logic-heavy forms.
  4. Typing Rohit fires input → NgModel's value accessor updates the FormControl → NgModel emits ngModelChange → the template statement assigns model.name. Both copies now say 'Rohit'.
  5. Leave out name inside a <form> and it throws at runtime: NG01352: If ngModel is used within a form tag, either the name attribute must be set or the form control must be defined as 'standalone' in ngModelOptions. with the examples <input [(ngModel)]="person.firstName" name="first"> and <input [(ngModel)]="person.firstName" [ngModelOptions]="{standalone: true}"> — verified.
AdvancedWhy plain fields break ngModel under OnPush

ngModel with signals, and the OnPush default

In Angular 22 components are OnPush by default (the ChangeDetectionStrategy docs in core's .d.ts: "OnPush is enabled by default"; the old behaviour is now called Eager). That matters for template-driven forms whose model is a plain property:

template/signal-model.ts
@Component({
  selector: 'app-tdf-signal',
  imports: [FormsModule],
  template: `
    <form #f="ngForm">
      <input id="name" name="name" [(ngModel)]="name" required />
      <input id="plain" name="plain" [(ngModel)]="plain" />
      <div ngModelGroup="address">
        <input id="city" name="city" [(ngModel)]="city" />
      </div>
      <input id="search" [(ngModel)]="search" [ngModelOptions]="{ standalone: true }" />
      <input id="lazy" name="lazy" [(ngModel)]="lazy" [ngModelOptions]="{ updateOn: 'blur' }" />
    </form>
  `,
})
export class TdfSignal {
  readonly name = signal('');
  plain = '';
  readonly city = signal('Toronto');
  search = '';
  lazy = '';
}
What’s happening
  1. [(ngModel)]="name" where name is a WritableSignal works: two-way binding to a signal reads it for the property side and calls .set() for the event side. Typing typed made c.name() return 'typed'; c.name.set('from signal') updated the input.
  2. Changing only the plain property in code — c.plain = 'plain only', then detectChanges() and whenStable() — left the input empty (verified). Nothing told Angular the OnPush view was dirty, so the [ngModel] binding was never re-read. User typing still works, because DOM events mark the view dirty — the bug shows up when you load data in code (an HTTP response, a "fill with defaults" button).
  3. Fix: hold template-driven models in signals (as above), or a signal of an object updated immutably. That's also consistent with Signals vs plain properties and OnPush.
  4. ngModelGroup="address" nests a FormGroup inside the form: the form value was {"name":…,"plain":…,"address":{"city":"Toronto"},"lazy":""}. It's the template-driven version of formGroupName.
  5. [ngModelOptions]="{ standalone: true }" keeps search out of the form entirely — no name needed, not in f.value, doesn't affect validity. { updateOn: 'blur' } delays model updates: after typing abc into lazy, c.lazy was still ''; after blur it was 'abc'.

#ngForm, validation and submit

Now the actual form. From my notes: "notice ngForm". #signupForm="ngForm" is a template reference variable that grabs the NgForm directive instance (its exportAs is ngForm) — not the <form> element. Likewise #nameCtrl="ngModel" grabs a field's NgModel directive, which exposes the same state as a FormControl: valid, invalid, touched, dirty, errors, hasError(). Validation is declared with HTML-style attributes — required, minlength, maxlength, pattern, email, min/max on number inputs — each of which is actually a validator directive from FormsModule.

template/signup.ts (complete template)
<form #signupForm="ngForm" (ngSubmit)="onSubmit(signupForm)">
  <input id="name" name="name" [(ngModel)]="model.name" #nameCtrl="ngModel" required minlength="2" />
  @if (nameCtrl.invalid && (nameCtrl.touched || signupForm.submitted)) {
    @if (nameCtrl.hasError('required')) { <p class="error">Name is required</p> }
    @if (nameCtrl.hasError('minlength')) {
      <p class="error">At least {{ nameCtrl.getError('minlength').requiredLength }} characters</p>
    }
  }

  <input id="email" name="email" type="email" [(ngModel)]="model.email" #emailCtrl="ngModel" required email />
  @if (emailCtrl.invalid && (emailCtrl.touched || signupForm.submitted)) {
    <p class="error">{{ emailCtrl.hasError('required') ? 'Email is required' : 'Enter a valid email' }}</p>
  }

  <input id="password" name="password" type="password" [(ngModel)]="model.password" required minlength="8" />
  <input id="newsletter" name="newsletter" type="checkbox" [(ngModel)]="model.newsletter" />
  <input id="plan" name="plan" [(ngModel)]="model.plan" [disabled]="true" />

  <button id="submit" type="submit">Sign up</button>
  <button id="reset" type="button" (click)="signupForm.resetForm()">Reset</button>
</form>
AdvancedTemplate-driven signup form class
template/signup.ts (class)
onSubmit(form: NgForm) {
  if (form.invalid) return;
  this.submitted.push({ ...form.value });
}
What’s happening
  1. "Complete name field looks like": name (registration key) + [(ngModel)] (two-way binding) + #nameCtrl="ngModel" (handle on its state) + required minlength="2" (validators). Blur the empty field → nameCtrl.touched → Name is required, classes ng-pristine ng-touched ng-invalid. Type R → At least 2 characters (verified). "Similarly we have the email field — same idea", with email as the validator attribute.
  2. The condition invalid && (touched || signupForm.submitted) shows errors after the user leaves a field or after a submit attempt. NgForm.submitted becomes true when the form is submitted — the template-driven replacement for markAllAsTouched(). Clicking Sign up on the empty form showed Name is required and Email is required and onSubmit returned early.
  3. (ngSubmit) is NgForm's output: it fires on the native submit (button click or Enter), after NgForm has prevented the page reload. Passing signupForm into onSubmit hands the class the NgForm instance; @ViewChild('signupForm') / viewChild<NgForm>('signupForm') would also work.
  4. After filling the fields and clicking the newsletter checkbox off, form.value was {"name":"Rohit","email":"rohit@example.com","password":"secret123","newsletter":false} while the model was the same plus "plan":"free". "Model vs form value": the form value is keyed by name attributes and excludes disabled controls; the model is your object with whatever it contains. signupForm.form.getRawValue() includes plan — .form is the underlying FormGroup.
  5. [disabled]="true" on an ngModel input is correct in template-driven forms: NgModel turns it into control.disable() (plan.disabled was true, and the input had the disabled property).
  6. "How to reset the form": resetForm() clears touched/dirty/submitted and sets every control to null — and because [(ngModel)] writes back, your model became {"name":null,"email":null,"password":null,"newsletter":null,"plan":null} (verified). To reset to defaults, pass them: resetForm({ name: '', email: '', password: '', newsletter: true }) restored those values, but plan — not in the object — still became null.
AdvancedPitfall: resetForm sets fields to null
What does this log?
HTML
<form #f="ngForm">
  <input name="first" [(ngModel)]="user.first" />
  <input name="surname" [(ngModel)]="user.last" />
</form>

With user = { first: 'Ada', last: 'Lovelace' }, after the form is stable, what are f.value and user? And what if you delete name="surname"?

Show answer

f.value is { first: 'Ada', surname: 'Lovelace' }; user is unchanged, { first: 'Ada', last: 'Lovelace' }. Without name="surname", creating the view throws NG01352.

What’s happening
  1. Each ngModel creates a FormControl and registers it in f's FormGroup under its name attribute — not under the property path in the binding.
  2. So the second control is registered as surname, and f.value reports surname: 'Lovelace'. The form value and the model are different objects with potentially different keys.
  3. [(ngModel)]="user.last" still writes to user.last on input; the model keeps its own shape.
  4. Remove name and NgModel can't register itself in the parent form, so it throws NG01352: If ngModel is used within a form tag, either the name attribute must be set or the form control must be defined as 'standalone' in ngModelOptions.

#Choosing a form approach

From my "final notes on forms": all three approaches sit on the same ideas — value, validity, touched/dirty, submit — and in two of them (Reactive and Template-driven) on literally the same FormControl classes. Pick by where you want the truth to live and how much logic the form has.

Signal FormsReactive FormsTemplate-driven
ImportFormField (@angular/forms/signals)ReactiveFormsModuleFormsModule
Source of truthYour model signal()FormGroup tree in the classngModel directives in the template
Template binding[formField]="f.email"[formGroup] + formControlName[(ngModel)] + name
SetupSynchronousSynchronousAsynchronous (controls appear after first CD)
ValidationSchema: required(s.x), validate, applyWhenValidators.*, validator functionsAttributes: required, minlength, email
Cross-field rulesvalidate with valueOfValidator on the parent groupCustom validator directive (awkward)
Conditional rulesDeclarative when / applyWhen / hiddenImperative (setValidators, enable/disable)@if + attribute bindings
Reading stateSignals: f.email().touched()Properties: ctrl.touched; observables#ctrl="ngModel": ctrl.touched
Reacting to changescomputed / effectvalueChanges (RxJS)(ngModelChange)
Disabled fieldsStay in the modelDrop from .value; getRawValue()Drop from form.value; model keeps them
ArraysArrays in the model + applyEachFormArrayAwkward (@for + unique names)
TypingFrom the model typeTyped forms, NonNullableFormBuilderOnly your model's type
Submitsubmit(form, action) / [formRoot](ngSubmit) + markAllAsTouched()(ngSubmit) + ngForm.submitted
Resetfield().reset(value?) (state; value optional)reset() → initial (non-nullable) or nullresetForm(value?) → null unless given values
Best forNew Angular 22 codeExisting apps, complex RxJS-driven formsSmall, simple forms; quick prototypes
What’s happening
  1. New code in Angular 22 → Signal Forms. It matches the rest of modern Angular (signals, OnPush, zoneless), keeps your model a plain typed object, and makes conditional and cross-field logic declarative.
  2. Existing reactive code, or heavy RxJS → Reactive Forms. They're synchronous, explicit, testable without a template, and valueChanges composes with every RxJS operator. Don't rewrite working forms; the compat APIs let you introduce Signal Forms gradually.
  3. A login box or a two-field dialog → Template-driven is fine: least code, but remember FormsModule, name attributes, async control creation, signals for the model under OnPush, and that resetForm() nulls the model.
  4. Whatever you choose, the UX rules are the same everywhere: show errors on touched (or after a submit attempt), not on dirty; mark everything touched on submit; treat disabled fields deliberately; and keep server errors on the field they belong to.

Built from Rohit’s “Angular interview prep” doc. Examples target Angular 22 and are compiled and tested with the Angular CLI and Vitest.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.