Forms

Reactive Forms

Build complex forms with Angular's reactive approach using FormGroup, FormControl, FormArray, and programmatic validators.

What Are Reactive Forms?

Reactive forms provide a model-driven approach where the form structure and validation are defined in the component class using TypeScript. This gives you:

  • Full control over the form model
  • Easy unit testing
  • Dynamic form construction
  • Complex cross-field validation
  • Observable-based value tracking

Setup

Import ReactiveFormsModule:

typescript
import { Component } from '@angular/core';
import { ReactiveFormsModule, FormGroup, FormControl, Validators } from '@angular/forms';

@Component({
  selector: 'app-login',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './login.component.html',
})
export class LoginComponent { }

Core Building Blocks

ClassPurpose
FormControlA single input field
FormGroupA group of controls
FormArrayA dynamic array of controls
FormBuilderHelper to create forms concisely

Creating a Reactive Form

With FormGroup and FormControl

typescript
@Component({
  selector: 'app-login',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
      <div>
        <label>Email</label>
        <input formControlName="email" type="email">
        @if (loginForm.get('email')?.invalid && loginForm.get('email')?.touched) {
          <span class="error">Valid email is required</span>
        }
      </div>

      <div>
        <label>Password</label>
        <input formControlName="password" type="password">
        @if (loginForm.get('password')?.invalid && loginForm.get('password')?.touched) {
          <span class="error">Password must be at least 6 characters</span>
        }
      </div>

      <label>
        <input type="checkbox" formControlName="rememberMe">
        Remember me
      </label>

      <button type="submit" [disabled]="loginForm.invalid">Log In</button>
    </form>
  `,
})
export class LoginComponent {
  loginForm = new FormGroup({
    email: new FormControl('', [Validators.required, Validators.email]),
    password: new FormControl('', [Validators.required, Validators.minLength(6)]),
    rememberMe: new FormControl(false),
  });

  onSubmit() {
    if (this.loginForm.valid) {
      console.log(this.loginForm.value);
      // { email: '...', password: '...', rememberMe: true/false }
    }
  }
}

With FormBuilder (Cleaner)

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

export class LoginComponent {
  private fb = inject(FormBuilder);

  loginForm = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(6)]],
    rememberMe: [false],
  });
}

Accessing Form Values

typescript
// Get entire form value
const formValue = this.loginForm.value;
// { email: 'user@test.com', password: '123456', rememberMe: true }

// Get individual control value
const email = this.loginForm.get('email')?.value;

// Using getRawValue (includes disabled controls)
const rawValue = this.loginForm.getRawValue();

Reacting to Value Changes

typescript
ngOnInit() {
  // Watch individual control
  this.loginForm.get('email')?.valueChanges.subscribe(value => {
    console.log('Email changed:', value);
  });

  // Watch entire form
  this.loginForm.valueChanges.subscribe(value => {
    console.log('Form changed:', value);
  });

  // Watch form status
  this.loginForm.statusChanges.subscribe(status => {
    console.log('Form status:', status); // 'VALID' | 'INVALID' | 'PENDING'
  });
}

Programmatically Updating Values

typescript
// Set a single control
this.loginForm.get('email')?.setValue('new@email.com');

// Patch partial values (only update specified fields)
this.loginForm.patchValue({
  email: 'updated@email.com',
});

// Set entire form value (all fields required)
this.loginForm.setValue({
  email: 'full@email.com',
  password: 'newpass123',
  rememberMe: true,
});

// Reset the form
this.loginForm.reset();

// Reset with specific values
this.loginForm.reset({ email: '', password: '', rememberMe: false });

Nested Form Groups

typescript
profileForm = this.fb.group({
  name: ['', Validators.required],
  email: ['', [Validators.required, Validators.email]],
  address: this.fb.group({
    street: ['', Validators.required],
    city: ['', Validators.required],
    state: [''],
    zip: ['', [Validators.required, Validators.pattern(/^\d{5,6}$/)]],
  }),
});
html
<form [formGroup]="profileForm" (ngSubmit)="save()">
  <input formControlName="name" placeholder="Name">
  <input formControlName="email" placeholder="Email">

  <div formGroupName="address">
    <input formControlName="street" placeholder="Street">
    <input formControlName="city" placeholder="City">
    <input formControlName="state" placeholder="State">
    <input formControlName="zip" placeholder="ZIP Code">
  </div>

  <button type="submit">Save</button>
</form>

FormArray — Dynamic Fields

FormArray lets you add/remove controls dynamically:

typescript
@Component({
  selector: 'app-skills-form',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
      <input formControlName="name" placeholder="Your name">

      <h3>Skills</h3>
      <div formArrayName="skills">
        @for (skill of skills.controls; track $index; let i = $index) {
          <div>
            <input [formControlName]="i" [placeholder]="'Skill ' + (i + 1)">
            <button type="button" (click)="removeSkill(i)">✕</button>
          </div>
        }
      </div>
      <button type="button" (click)="addSkill()">+ Add Skill</button>

      <button type="submit">Save</button>
    </form>
  `,
})
export class SkillsFormComponent {
  private fb = inject(FormBuilder);

  form = this.fb.group({
    name: ['', Validators.required],
    skills: this.fb.array([
      this.fb.control('', Validators.required),
    ]),
  });

  get skills() {
    return this.form.get('skills') as FormArray;
  }

  addSkill() {
    this.skills.push(this.fb.control('', Validators.required));
  }

  removeSkill(index: number) {
    this.skills.removeAt(index);
  }

  onSubmit() {
    console.log(this.form.value);
    // { name: 'John', skills: ['TypeScript', 'Angular', 'RxJS'] }
  }
}

Typed Forms (Angular 14+)

Angular's reactive forms are strictly typed by default:

typescript
// TypeScript knows the exact shape
const form = this.fb.group({
  name: [''],
  age: [0],
  active: [true],
});

const name: string | null = form.value.name;     // Typed!
const age: number | null = form.value.age;         // Typed!
const active: boolean | null = form.value.active;  // Typed!

Non-Nullable Forms

typescript
const form = this.fb.nonNullable.group({
  name: [''],
  age: [0],
});

// Values are never null after reset
form.reset();
console.log(form.value.name); // '' (not null)

Enabling/Disabling Controls

typescript
// Disable a control
this.form.get('email')?.disable();

// Enable a control
this.form.get('email')?.enable();

// Disable on condition
if (this.isReadOnly) {
  this.form.disable();
}

Note: Disabled controls are excluded from form.value. Use form.getRawValue() to include them.

Complete Example: Order Form

typescript
@Component({
  selector: 'app-order-form',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="orderForm" (ngSubmit)="placeOrder()">
      <h2>Place Order</h2>

      <div formGroupName="customer">
        <input formControlName="name" placeholder="Full Name">
        <input formControlName="email" placeholder="Email">
        <input formControlName="phone" placeholder="Phone">
      </div>

      <h3>Items</h3>
      <div formArrayName="items">
        @for (item of items.controls; track $index; let i = $index) {
          <div [formGroupName]="i" class="item-row">
            <input formControlName="product" placeholder="Product">
            <input formControlName="quantity" type="number" min="1">
            <input formControlName="price" type="number" step="0.01">
            <button type="button" (click)="removeItem(i)">Remove</button>
          </div>
        }
      </div>
      <button type="button" (click)="addItem()">+ Add Item</button>

      <p><strong>Total: {{ calculateTotal() | currency }}</strong></p>

      <button type="submit" [disabled]="orderForm.invalid">
        Place Order
      </button>
    </form>
  `,
})
export class OrderFormComponent {
  private fb = inject(FormBuilder);

  orderForm = this.fb.group({
    customer: this.fb.group({
      name: ['', Validators.required],
      email: ['', [Validators.required, Validators.email]],
      phone: [''],
    }),
    items: this.fb.array([this.createItem()]),
  });

  get items() {
    return this.orderForm.get('items') as FormArray;
  }

  createItem() {
    return this.fb.group({
      product: ['', Validators.required],
      quantity: [1, [Validators.required, Validators.min(1)]],
      price: [0, [Validators.required, Validators.min(0.01)]],
    });
  }

  addItem() { this.items.push(this.createItem()); }
  removeItem(i: number) { this.items.removeAt(i); }

  calculateTotal(): number {
    return this.items.controls.reduce((sum, item) => {
      const qty = item.get('quantity')?.value || 0;
      const price = item.get('price')?.value || 0;
      return sum + qty * price;
    }, 0);
  }

  placeOrder() {
    if (this.orderForm.valid) {
      console.log('Order:', this.orderForm.value);
    }
  }
}

Next Steps

Forms need validation. Next, we'll explore form validation in depth — custom validators, async validators, and cross-field validation strategies.