FormGroup and FormControl — Building the Reactive Form Model

📋 Table of Contents
  1. Reactive Form Setup
  2. Common Mistakes

Reactive forms define the form model programmatically in the component class — a FormGroup holding FormControl instances, each with initial values and validators. This explicit model makes the form fully testable (no DOM required), provides type-safe access to values and errors in TypeScript, and enables programmatic operations like conditional validation, dynamic field management, and cross-field rules. Angular 14+ introduced strongly-typed reactive forms — form.value.title is typed as string, not any.

Reactive Form Setup

import { Component, signal, inject, OnInit } from '@angular/core';
import { ReactiveFormsModule, FormBuilder,
         Validators, FormGroup } from '@angular/forms';

@Component({
  selector:   'app-post-form',
  standalone:  true,
  imports:    [ReactiveFormsModule],
  template: `
    <!-- [formGroup] binds the template to the FormGroup ─────────────────── -->
    <form [formGroup]="form" (ngSubmit)="onSubmit()" novalidate>

      <div class="field">
        <label>Title *</label>
        <!-- formControlName connects to form.controls.title ──────────────── -->
        <input formControlName="title" type="text">
        @if (form.controls.title.invalid && form.controls.title.touched) {
          <span class="error">
            @if (form.controls.title.hasError('required'))  { Title is required. }
            @if (form.controls.title.hasError('minlength')) {
              At least {{ form.controls.title.errors?.['minlength'].requiredLength }} chars needed.
            }
          </span>
        }
      </div>

      <div class="field">
        <label>Excerpt</label>
        <textarea formControlName="excerpt" rows="3"></textarea>
      </div>

      <div class="field">
        <label>Status</label>
        <select formControlName="status">
          <option value="draft">Draft</option>
          <option value="review">In Review</option>
          <option value="published">Published</option>
        </select>
      </div>

      <button type="submit" [disabled]="form.invalid || isSaving()">
        Save
      </button>
    </form>
  `,
})
export class PostFormComponent implements OnInit {
  private fb      = inject(FormBuilder);
  private api     = inject(PostsApiService);
  isSaving        = signal(false);

  // ── FormBuilder.nonNullable: values are never null (strong typing) ────────
  form = this.fb.nonNullable.group({
    title:   ['', [Validators.required, Validators.minLength(5), Validators.maxLength(200)]],
    slug:    ['', [Validators.required, Validators.pattern(/^[a-z0-9-]+$/)]],
    excerpt: ['', Validators.maxLength(500)],
    body:    ['', [Validators.required, Validators.minLength(50)]],
    status:  ['draft' as 'draft' | 'review' | 'published'],
  });

  ngOnInit() {
    // ── React to value changes ────────────────────────────────────────────
    this.form.controls.title.valueChanges
      .pipe(takeUntilDestroyed())
      .subscribe(title => {
        // Auto-generate slug from title
        const slug = title.toLowerCase()
          .replace(/[^a-z0-9\s-]/g, '')
          .replace(/\s+/g, '-');
        this.form.controls.slug.setValue(slug, { emitEvent: false });
      });
  }

  onSubmit(): void {
    if (this.form.invalid) {
      this.form.markAllAsTouched();  // show all errors
      return;
    }
    this.isSaving.set(true);
    const request = this.form.getRawValue();  // typed: { title: string, slug: string, ... }
    this.api.create(request).subscribe({
      next:  () => this.isSaving.set(false),
      error: () => this.isSaving.set(false),
    });
  }
}
Note: form.getRawValue() returns values for all controls including disabled ones. form.value excludes disabled controls. For a post edit form where some fields are conditionally disabled (slug cannot be changed after publish), always use getRawValue() to ensure you have the complete form data for submission. The return type of getRawValue() is strongly typed based on the FormGroup definition — form.getRawValue().title is typed as string, not any.
Tip: Use FormBuilder.nonNullable.group() instead of fb.group() for forms where controls should never hold null. With nonNullable, resetting a control restores its initial value (e.g., empty string) rather than setting it to null. This gives better TypeScript types — form.controls.title.value is typed as string rather than string | null, eliminating unnecessary null checks throughout the component.
Warning: form.setValue() requires providing values for all controls — omitting any control throws a runtime error. form.patchValue() only updates the provided controls and safely ignores missing ones. For edit forms where you load partial data, always use patchValue(). For resetting a form with known complete data, setValue() is safer — the compiler catches missing properties. Use the right one intentionally: patchValue for partial updates, setValue for complete replacements.

Common Mistakes

Mistake 1 — Using form.value instead of form.getRawValue() (missing disabled control values)

❌ Wrong — disabled slug control returns undefined in form.value; submitted data is incomplete.

✅ Correct — always use form.getRawValue() to include all control values regardless of disabled state.

Mistake 2 — Setting emitEvent: true when programmatically updating values (infinite loop)

❌ Wrong — updating slug in a title valueChanges handler without { emitEvent: false }; slug change triggers valueChanges which updates slug again…

✅ Correct — use form.controls.slug.setValue(value, { emitEvent: false }) for programmatic updates inside value change handlers.

🧠 Test Yourself

A FormGroup has controls: title (required), slug (required), body (required). form.patchValue({ title: 'Hello' }) is called. What happens to the slug and body controls?