FormControl, FormGroup, FormBuilder, setValue, patchValue, valueChanges and statusChanges

Angular Tutorial: Beginner to Advanced · 8. Forms and validation · 6 min read · Updated

The reactive forms lesson showed a form in action. Here we open the toolbox: what each class does, how to fill a form with data, and how to react while the user types.

What you will learn

  • FormControl, FormGroup and nested groups
  • FormBuilder as a shortcut
  • setValue() versus patchValue() and reset()
  • valueChanges and statusChanges

FormControl: one field

A FormControl holds the value and the state of one input:

import { FormControl, Validators } from '@angular/forms';

const name = new FormControl('', { nonNullable: true, validators: [Validators.required] });

name.value; // ''
name.valid; // false (empty and required)
name.setValue('Ada');
name.valid; // true
name.touched; // false until the user leaves the field
name.dirty; // false until the user changes it

nonNullable: true means reset() returns to the initial value instead of null, and the type stays string.

FormGroup: a set of controls

A FormGroup combines controls into one object. Groups can contain other groups, which models nested data such as an address:

// src/app/forms2/profile-form.ts
import { JsonPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';

@Component({
  selector: 'app-profile-form',
  imports: [ReactiveFormsModule, JsonPipe],
  template: `
    <form [formGroup]="form" (ngSubmit)="save()">
      <p>
        <label for="name">Name</label><br />
        <input id="name" formControlName="name" />
      </p>
      <p>
        <label for="email">Email</label><br />
        <input id="email" formControlName="email" />
      </p>

      <fieldset formGroupName="address">
        <legend>Address</legend>
        <p>
          <label for="city">City</label><br />
          <input id="city" formControlName="city" />
        </p>
        <p>
          <label for="zip">ZIP</label><br />
          <input id="zip" formControlName="zip" />
        </p>
      </fieldset>

      <button type="submit" [disabled]="form.invalid">Save</button>
      <button type="button" (click)="loadAll()">Load all (setValue)</button>
      <button type="button" (click)="loadSome()">Load some (patchValue)</button>
      <button type="button" (click)="clear()">Reset</button>
    </form>

    <p>Status: {{ form.status }} | changes seen: {{ changes }}</p>
    <pre>{{ form.getRawValue() | json }}</pre>
  `,
})
export class ProfileForm {
  private readonly fb = inject(FormBuilder).nonNullable;

  protected readonly form = this.fb.group({
    name: ['', Validators.required],
    email: ['', [Validators.required, Validators.email]],
    address: this.fb.group({
      city: ['', Validators.required],
      zip: ['', Validators.pattern(/^\d{5}$/)],
    }),
  });

  protected changes = 0;
  protected lastStatus = '';

  constructor() {
    // Runs on every value change, with the whole form value.
    this.form.valueChanges.subscribe(() => this.changes++);

    // Runs when the validity status changes: VALID, INVALID, PENDING or DISABLED.
    this.form.statusChanges.subscribe((status) => (this.lastStatus = status));
  }

  protected loadAll(): void {
    // setValue needs EVERY field, in the exact shape of the form.
    this.form.setValue({
      name: 'Ada Lovelace',
      email: 'ada@example.com',
      address: { city: 'London', zip: '12345' },
    });
  }

  protected loadSome(): void {
    // patchValue accepts a part of the shape, and leaves the rest alone.
    this.form.patchValue({ address: { city: 'Paris' } });
  }

  protected clear(): void {
    this.form.reset(); // back to the initial values, and untouched/pristine
  }

  protected save(): void {
    console.log('Saving', this.form.getRawValue());
  }
}
// src/app/app.ts
import { Component } from '@angular/core';
import { ProfileForm } from './forms2/profile-form';

@Component({
  imports: [ProfileForm],
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
  <h1>Profile</h1>
  <app-profile-form />
</main>

FormBuilder: less typing

fb.group({...}) is the short way to write new FormGroup({ name: new FormControl(...) }). Each value is a control definition: ['initial', validators]. Using inject(FormBuilder).nonNullable makes every control non-nullable. You can also use fb.control(...) and fb.array(...).

setValue versus patchValue

  • Required shape: setValue(value): Every field must be given; patchValue(value): A partial object is fine
  • Missing or extra field: setValue(value): Throws an error; patchValue(value): Ignored
  • Use for: setValue(value): Loading a complete, trusted record; patchValue(value): Updating some fields, or loading partial data

Prefer patchValue when data comes from a server, because a missing field will not crash the page. Prefer setValue when you want the compiler and runtime to confirm that nothing was forgotten.

The same two methods work on a single control, on groups, and on FormArray (the dynamic forms lesson). For a FormArray, setValue needs the same number of items as the array has controls, and patchValue updates items by position.

Template-driven forms can be filled too: with ngModel, assign the property in your class (this.model = {...}), or use form.setValue() and form.form.patchValue() on the NgForm instance you get from #f="ngForm".

reset

form.reset() clears the values to their initial value (or null for nullable controls) and marks the form untouched and pristine, so error messages disappear. form.reset({ name: 'Ada' }) resets to chosen values.

valueChanges and statusChanges

Both are Observables available on every control, group and array:

  • valueChanges emits the new value each time it changes.
  • statusChanges emits 'VALID', 'INVALID', 'PENDING' (an async validator is running) or 'DISABLED'.

A common use is reacting to a single field with RxJS operators:

// src/app/forms2/live-search.ts
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged, map } from 'rxjs';

@Component({
  selector: 'app-live-search',
  imports: [ReactiveFormsModule],
  template: `
    <label for="q">Search</label>
    <input id="q" [formControl]="query" />
    <p>Searching for: {{ debounced() }}</p>
  `,
})
export class LiveSearch {
  protected readonly query = new FormControl('', { nonNullable: true });

  protected readonly debounced = toSignal(
    this.query.valueChanges.pipe(debounceTime(300), distinctUntilChanged(), map((v) => v.trim())),
    { initialValue: '' },
  );

  constructor() {
    this.query.statusChanges.pipe(takeUntilDestroyed()).subscribe((s) => console.log('status', s));
  }
}

By default valueChanges and statusChanges also fire for changes made by code. Pass { emitEvent: false } to setValue or patchValue to avoid triggering them, which prevents loops when you update a form from its own subscription.

Reading and changing state

Handy properties on every control: value, valid, invalid, pending, errors, touched, untouched, dirty, pristine, disabled, enabled. Handy methods: enable(), disable(), markAsTouched(), markAllAsTouched(), markAsPristine(), setErrors(), updateValueAndValidity(). getRawValue() includes disabled controls, while value leaves them out.

Test what we claimed

// src/app/forms2/profile-form.spec.ts
import { TestBed } from '@angular/core/testing';
import { FormBuilder } from '@angular/forms';

describe('setValue vs patchValue', () => {
  function makeForm() {
    const fb = TestBed.inject(FormBuilder).nonNullable;
    return fb.group({ name: [''], address: fb.group({ city: [''], zip: [''] }) });
  }

  it('patchValue accepts a part of the shape', () => {
    const form = makeForm();
    form.patchValue({ address: { city: 'Paris' } });
    expect(form.getRawValue()).toEqual({ name: '', address: { city: 'Paris', zip: '' } });
  });

  it('setValue throws when a field is missing', () => {
    const form = makeForm();
    expect(() => form.setValue({ name: 'x' } as never)).toThrow();
  });

  it('reset returns to the initial values', () => {
    const form = makeForm();
    form.patchValue({ name: 'Ada' });
    form.controls.name.markAsDirty();
    form.reset();
    expect(form.getRawValue().name).toBe('');
    expect(form.pristine).toBe(true);
  });

  it('valueChanges emits on every change', () => {
    const form = makeForm();
    const seen: string[] = [];
    form.controls.name.valueChanges.subscribe((v) => seen.push(v));
    form.controls.name.setValue('a');
    form.controls.name.setValue('ab', { emitEvent: false });
    form.controls.name.setValue('abc');
    expect(seen).toEqual(['a', 'abc']);
  });
});

Try it yourself

  1. Add a "Copy billing to shipping" button that uses patchValue between two groups.
  2. Show a banner when statusChanges reports INVALID after the user touched a field.
  3. Use emitEvent: false to update one field from inside another field's valueChanges without a loop.

Quick check

  1. What happens if setValue misses a field?
  2. Which method clears values and resets touched and dirty state?
  3. What does statusChanges emit?

Next, build every kind of field in select, checkbox and radio controls. See the reactive forms guide.

Frequently asked questions

Should I subscribe to valueChanges for everything?

No. Use it for reactions such as search or dependent fields. For reading values on submit, call form.getRawValue() or form.value.

Why is my disabled field missing from form.value?

value leaves out disabled controls. Use getRawValue() to include them.