Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Angular Forms

Module Overview

Estimated Time: 4-5 hours | Difficulty: Intermediate | Prerequisites: Module 6
Angular provides two approaches to handling forms: template-driven forms for simple cases and reactive forms for complex scenarios. Both provide validation, error handling, and form state management. Forms are often the most complex part of any frontend application — they involve user input, validation, error messages, async checks (like “is this username taken?”), conditional fields, and state management. Getting forms right is the difference between an app that feels polished and one that frustrates users.
Which should you use? For a contact form or a simple login page, template-driven forms are faster to build. For anything with dynamic fields, multi-step wizards, complex validation logic, or forms you need to unit test thoroughly, use reactive forms. In production codebases, reactive forms are overwhelmingly more common because most real-world forms are more complex than tutorials suggest.
What You’ll Learn:
  • Template-driven forms basics
  • Reactive forms deep dive
  • Built-in and custom validators
  • Dynamic forms and FormArrays
  • Form state management
  • Accessibility best practices

Forms Comparison

┌─────────────────────────────────────────────────────────────────────────┐
│              Template-Driven vs Reactive Forms                           │
├─────────────────────────────┬───────────────────────────────────────────┤
│      Template-Driven        │            Reactive Forms                  │
├─────────────────────────────┼───────────────────────────────────────────┤
│ • Logic in template         │ • Logic in component class                │
│ • Uses ngModel directive    │ • Uses FormControl, FormGroup             │
│ • Two-way data binding      │ • Explicit data flow                      │
│ • Less boilerplate          │ • More testable                           │
│ • Good for simple forms     │ • Good for complex forms                  │
│ • Async validation tricky   │ • Easy async validation                   │
│                             │ • Dynamic form generation                 │
│                             │ • Better type safety                      │
└─────────────────────────────┴───────────────────────────────────────────┘

Template-Driven Forms

Basic Setup

// Import FormsModule for template-driven forms
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-contact-form',
  standalone: true,
  imports: [FormsModule],
  template: `
    <form #contactForm="ngForm" (ngSubmit)="onSubmit(contactForm)">
      <div class="form-group">
        <label for="name">Name</label>
        <input 
          type="text"
          id="name"
          name="name"
          [(ngModel)]="model.name"
          required
          minlength="2"
          #name="ngModel"
        />
        @if (name.invalid && name.touched) {
          <div class="errors">
            @if (name.errors?.['required']) {
              <span>Name is required</span>
            }
            @if (name.errors?.['minlength']) {
              <span>Name must be at least 2 characters</span>
            }
          </div>
        }
      </div>
      
      <div class="form-group">
        <label for="email">Email</label>
        <input 
          type="email"
          id="email"
          name="email"
          [(ngModel)]="model.email"
          required
          email
          #email="ngModel"
        />
        @if (email.invalid && email.touched) {
          <div class="errors">
            @if (email.errors?.['required']) {
              <span>Email is required</span>
            }
            @if (email.errors?.['email']) {
              <span>Invalid email format</span>
            }
          </div>
        }
      </div>
      
      <div class="form-group">
        <label for="message">Message</label>
        <textarea
          id="message"
          name="message"
          [(ngModel)]="model.message"
          required
          rows="4"
        ></textarea>
      </div>
      
      <button type="submit" [disabled]="contactForm.invalid">
        Send Message
      </button>
      
      <pre>Form valid: {{ contactForm.valid }}</pre>
      <pre>Model: {{ model | json }}</pre>
    </form>
  `
})
export class ContactFormComponent {
  model = {
    name: '',
    email: '',
    message: ''
  };
  
  onSubmit(form: NgForm) {
    if (form.valid) {
      console.log('Form submitted:', this.model);
      form.reset();
    }
  }
}

Reactive Forms

Setup and Basic Usage

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

@Component({
  selector: 'app-signup-form',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="signupForm" (ngSubmit)="onSubmit()">
      <div class="form-group">
        <label for="username">Username</label>
        <input 
          id="username"
          type="text"
          formControlName="username"
        />
        @if (signupForm.get('username')?.invalid && signupForm.get('username')?.touched) {
          <div class="errors">
            @if (signupForm.get('username')?.errors?.['required']) {
              <span>Username is required</span>
            }
            @if (signupForm.get('username')?.errors?.['minlength']) {
              <span>Username must be at least 3 characters</span>
            }
          </div>
        }
      </div>
      
      <div class="form-group">
        <label for="email">Email</label>
        <input 
          id="email"
          type="email"
          formControlName="email"
        />
      </div>
      
      <div class="form-group">
        <label for="password">Password</label>
        <input 
          id="password"
          type="password"
          formControlName="password"
        />
      </div>
      
      <div class="form-group">
        <label for="confirmPassword">Confirm Password</label>
        <input 
          id="confirmPassword"
          type="password"
          formControlName="confirmPassword"
        />
        @if (signupForm.errors?.['passwordMismatch']) {
          <span class="error">Passwords must match</span>
        }
      </div>
      
      <button type="submit" [disabled]="signupForm.invalid">
        Sign Up
      </button>
    </form>
  `
})
export class SignupFormComponent {
  private fb = inject(FormBuilder);
  
  signupForm = this.fb.group({
    username: ['', [Validators.required, Validators.minLength(3)]],
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(8)]],
    confirmPassword: ['', Validators.required]
  }, {
    validators: this.passwordMatchValidator
  });
  
  passwordMatchValidator(group: FormGroup) {
    const password = group.get('password')?.value;
    const confirmPassword = group.get('confirmPassword')?.value;
    return password === confirmPassword ? null : { passwordMismatch: true };
  }
  
  onSubmit() {
    if (this.signupForm.valid) {
      console.log('Form data:', this.signupForm.value);
    }
  }
}

FormControl, FormGroup, FormArray

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

// FormControl - single input
const nameControl = new FormControl('', [Validators.required]);
console.log(nameControl.value);    // ''
console.log(nameControl.valid);    // false
console.log(nameControl.errors);   // { required: true }

// FormGroup - group of controls
const userForm = new FormGroup({
  name: new FormControl('', Validators.required),
  email: new FormControl('', [Validators.required, Validators.email]),
  address: new FormGroup({  // Nested group
    street: new FormControl(''),
    city: new FormControl(''),
    zip: new FormControl('')
  })
});

// Access nested controls
userForm.get('address.city')?.setValue('New York');

// FormArray - dynamic array of controls
const skills = new FormArray([
  new FormControl('TypeScript'),
  new FormControl('Angular')
]);

// Add/remove items dynamically
skills.push(new FormControl('RxJS'));
skills.removeAt(0);

FormBuilder

The FormBuilder provides a cleaner API:
@Component({...})
export class ProfileFormComponent {
  private fb = inject(FormBuilder);
  
  profileForm = this.fb.group({
    // Shorthand: ['initialValue', validators]
    firstName: ['', Validators.required],
    lastName: ['', Validators.required],
    
    // With async validators
    email: ['', [Validators.required, Validators.email], [this.emailExistsValidator]],
    
    // Nested FormGroup
    address: this.fb.group({
      street: [''],
      city: [''],
      state: [''],
      zip: ['', Validators.pattern(/^\d{5}$/)]
    }),
    
    // FormArray
    phones: this.fb.array([
      this.fb.control('')
    ]),
    
    // With options
    newsletter: this.fb.control(false, { updateOn: 'blur' })
  });
  
  // FormArray helpers
  get phones() {
    return this.profileForm.get('phones') as FormArray;
  }
  
  addPhone() {
    this.phones.push(this.fb.control(''));
  }
  
  removePhone(index: number) {
    this.phones.removeAt(index);
  }
}

Strongly Typed Forms

Angular 14+ provides strongly typed reactive forms. Before this feature, form.value returned any, which meant typos in field names would not be caught until runtime. With typed forms, the compiler catches mistakes at build time — if you rename a field in your interface but forget to update the form, TypeScript tells you immediately.
interface UserFormModel {
  name: string;
  email: string;
  age: number;
  preferences: {
    newsletter: boolean;
    theme: 'light' | 'dark';
  };
  tags: string[];
}

@Component({...})
export class TypedFormComponent {
  private fb = inject(NonNullableFormBuilder);
  
  // Fully typed form
  userForm = this.fb.group({
    name: ['', Validators.required],
    email: ['', [Validators.required, Validators.email]],
    age: [18, [Validators.min(0), Validators.max(120)]],
    preferences: this.fb.group({
      newsletter: [false],
      theme: ['light' as const]
    }),
    tags: this.fb.array<string>([])
  });
  
  // Type-safe access
  updateTheme(theme: 'light' | 'dark') {
    this.userForm.controls.preferences.controls.theme.setValue(theme);
  }
  
  // getRawValue() returns properly typed object
  onSubmit() {
    const data = this.userForm.getRawValue();
    // data is typed as UserFormModel
    console.log(data.name, data.preferences.theme);
  }
}

Custom Validators

Synchronous Validators

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

// Factory function for configurable validator
export function minAge(min: number): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const age = control.value;
    if (age < min) {
      return { minAge: { required: min, actual: age } };
    }
    return null;
  };
}

// Simple validator
export function noWhitespace(control: AbstractControl): ValidationErrors | null {
  const hasWhitespace = /\s/.test(control.value);
  return hasWhitespace ? { noWhitespace: true } : null;
}

// Cross-field validator (for FormGroup)
export function dateRangeValidator(
  startField: string, 
  endField: string
): ValidatorFn {
  return (group: AbstractControl): ValidationErrors | null => {
    const start = group.get(startField)?.value;
    const end = group.get(endField)?.value;
    
    if (start && end && new Date(start) > new Date(end)) {
      return { dateRange: { message: 'Start date must be before end date' } };
    }
    return null;
  };
}

// Usage
const form = this.fb.group({
  age: [25, [Validators.required, minAge(18)]],
  username: ['', [Validators.required, noWhitespace]],
  startDate: [''],
  endDate: ['']
}, {
  validators: dateRangeValidator('startDate', 'endDate')
});

Asynchronous Validators

import { AsyncValidatorFn } from '@angular/forms';
import { Observable, of, timer } from 'rxjs';
import { map, catchError, switchMap } from 'rxjs/operators';

// Async validator using inject()
export function usernameExistsValidator(): AsyncValidatorFn {
  const userService = inject(UserService);
  
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    if (!control.value) {
      return of(null);
    }
    
    // Debounce to avoid too many API calls
    return timer(300).pipe(
      switchMap(() => userService.checkUsername(control.value)),
      map(exists => exists ? { usernameTaken: true } : null),
      catchError(() => of(null))
    );
  };
}

// Usage
const form = this.fb.group({
  username: [
    '',
    [Validators.required],
    [usernameExistsValidator()]  // Async validators as third argument
  ]
});

Dynamic Forms with FormArray

@Component({
  selector: 'app-order-form',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="orderForm" (ngSubmit)="onSubmit()">
      <h2>Order Items</h2>
      
      <div formArrayName="items">
        @for (item of items.controls; track i; let i = $index) {
          <div class="item-row" [formGroupName]="i">
            <input formControlName="name" placeholder="Item name" />
            <input formControlName="quantity" type="number" min="1" />
            <input formControlName="price" type="number" min="0" step="0.01" />
            
            <span class="subtotal">
              ${{ getSubtotal(i) | number:'1.2-2' }}
            </span>
            
            <button type="button" (click)="removeItem(i)">Remove</button>
          </div>
        }
      </div>
      
      <button type="button" (click)="addItem()">Add Item</button>
      
      <div class="summary">
        <strong>Total: ${{ total | number:'1.2-2' }}</strong>
      </div>
      
      <button type="submit" [disabled]="orderForm.invalid">
        Place Order
      </button>
    </form>
  `
})
export class OrderFormComponent {
  private fb = inject(FormBuilder);
  
  orderForm = this.fb.group({
    customerName: ['', Validators.required],
    items: this.fb.array([this.createItemGroup()])
  });
  
  get items(): FormArray {
    return this.orderForm.get('items') as FormArray;
  }
  
  get total(): number {
    return this.items.controls.reduce((sum, control, i) => {
      return sum + this.getSubtotal(i);
    }, 0);
  }
  
  createItemGroup(): FormGroup {
    return this.fb.group({
      name: ['', Validators.required],
      quantity: [1, [Validators.required, Validators.min(1)]],
      price: [0, [Validators.required, Validators.min(0)]]
    });
  }
  
  addItem() {
    this.items.push(this.createItemGroup());
  }
  
  removeItem(index: number) {
    this.items.removeAt(index);
  }
  
  getSubtotal(index: number): number {
    const item = this.items.at(index);
    return (item.get('quantity')?.value || 0) * (item.get('price')?.value || 0);
  }
  
  onSubmit() {
    if (this.orderForm.valid) {
      console.log('Order:', this.orderForm.value);
    }
  }
}

Form State and CSS Classes

Angular automatically applies CSS classes based on form state:
┌─────────────────────────────────────────────────────────────────────────┐
│                    Form Control States & CSS Classes                     │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   State         │ True Class      │ False Class                         │
│   ──────────────┼─────────────────┼─────────────────                    │
│   visited       │ .ng-touched     │ .ng-untouched                       │
│   changed       │ .ng-dirty       │ .ng-pristine                        │
│   valid         │ .ng-valid       │ .ng-invalid                         │
│   pending       │ .ng-pending     │ (none)                              │
│                                                                          │
│   Example CSS:                                                           │
│   ─────────────                                                          │
│   input.ng-invalid.ng-touched {                                         │
│     border-color: red;                                                   │
│   }                                                                      │
│                                                                          │
│   input.ng-valid.ng-touched {                                           │
│     border-color: green;                                                 │
│   }                                                                      │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘
/* forms.scss */
.form-control {
  &.ng-invalid.ng-touched {
    border-color: #dc3545;
    
    &:focus {
      box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
    }
  }
  
  &.ng-valid.ng-touched {
    border-color: #28a745;
  }
  
  &.ng-pending {
    border-color: #ffc107;
  }
}

.error-message {
  color: #dc3545;
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

Error Handling Component

Create a reusable error display component:
// form-errors.component.ts
@Component({
  selector: 'app-form-errors',
  standalone: true,
  template: `
    @if (control && control.invalid && (control.dirty || control.touched)) {
      <div class="error-messages" role="alert">
        @for (error of errorMessages; track error) {
          <span class="error">{{ error }}</span>
        }
      </div>
    }
  `,
  styles: [`
    .error-messages {
      color: #dc3545;
      font-size: 0.875rem;
    }
    .error { display: block; }
  `]
})
export class FormErrorsComponent {
  control = input<AbstractControl | null>(null);
  
  private errorMessages$ = {
    required: 'This field is required',
    email: 'Please enter a valid email',
    minlength: (error: any) => `Minimum ${error.requiredLength} characters required`,
    maxlength: (error: any) => `Maximum ${error.requiredLength} characters allowed`,
    min: (error: any) => `Minimum value is ${error.min}`,
    max: (error: any) => `Maximum value is ${error.max}`,
    pattern: 'Invalid format'
  };
  
  get errorMessages(): string[] {
    const control = this.control();
    if (!control || !control.errors) return [];
    
    return Object.keys(control.errors).map(key => {
      const errorDef = this.errorMessages$[key];
      if (typeof errorDef === 'function') {
        return errorDef(control.errors![key]);
      }
      return errorDef || `Validation error: ${key}`;
    });
  }
}

// Usage
@Component({
  template: `
    <input formControlName="email" />
    <app-form-errors [control]="form.get('email')" />
  `,
  imports: [FormErrorsComponent, ReactiveFormsModule]
})

Form Accessibility

@Component({
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
      <div class="form-field">
        <label for="email">
          Email
          <span class="required" aria-hidden="true">*</span>
        </label>
        <input
          id="email"
          type="email"
          formControlName="email"
          [attr.aria-invalid]="form.get('email')?.invalid"
          [attr.aria-describedby]="form.get('email')?.errors ? 'email-error' : null"
        />
        @if (form.get('email')?.invalid && form.get('email')?.touched) {
          <div id="email-error" class="error" role="alert" aria-live="polite">
            Please enter a valid email address
          </div>
        }
      </div>
      
      <div class="form-field">
        <fieldset>
          <legend>Notification Preferences</legend>
          <label>
            <input type="checkbox" formControlName="emailNotifs" />
            Email notifications
          </label>
          <label>
            <input type="checkbox" formControlName="smsNotifs" />
            SMS notifications
          </label>
        </fieldset>
      </div>
      
      <button 
        type="submit" 
        [disabled]="form.invalid"
        [attr.aria-disabled]="form.invalid"
      >
        Submit
      </button>
    </form>
  `
})
export class AccessibleFormComponent {
  form = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    emailNotifs: [true],
    smsNotifs: [false]
  });
}

Practice Exercise

Exercise: Build a Multi-Step Registration Form

Create a registration wizard with:
  1. Step 1: Personal info (name, email)
  2. Step 2: Account details (username, password)
  3. Step 3: Preferences (newsletter, theme)
  4. Navigation between steps
  5. Validation per step
  6. Summary and submit on final step
interface RegistrationForm {
  personal: {
    firstName: string;
    lastName: string;
    email: string;
  };
  account: {
    username: string;
    password: string;
    confirmPassword: string;
  };
  preferences: {
    newsletter: boolean;
    theme: 'light' | 'dark';
    notifications: string[];
  };
}

@Component({
  selector: 'app-registration-wizard',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <div class="wizard">
      <div class="steps">
        <span [class.active]="currentStep() === 1">1. Personal</span>
        <span [class.active]="currentStep() === 2">2. Account</span>
        <span [class.active]="currentStep() === 3">3. Preferences</span>
      </div>
      
      <form [formGroup]="form" (ngSubmit)="onSubmit()">
        <!-- Step 1: Personal Info -->
        @if (currentStep() === 1) {
          <div formGroupName="personal">
            <h2>Personal Information</h2>
            
            <div class="field">
              <label>First Name</label>
              <input formControlName="firstName" />
            </div>
            
            <div class="field">
              <label>Last Name</label>
              <input formControlName="lastName" />
            </div>
            
            <div class="field">
              <label>Email</label>
              <input type="email" formControlName="email" />
            </div>
          </div>
        }
        
        <!-- Step 2: Account Details -->
        @if (currentStep() === 2) {
          <div formGroupName="account">
            <h2>Account Details</h2>
            
            <div class="field">
              <label>Username</label>
              <input formControlName="username" />
            </div>
            
            <div class="field">
              <label>Password</label>
              <input type="password" formControlName="password" />
            </div>
            
            <div class="field">
              <label>Confirm Password</label>
              <input type="password" formControlName="confirmPassword" />
            </div>
            
            @if (form.get('account')?.errors?.['passwordMismatch']) {
              <span class="error">Passwords don't match</span>
            }
          </div>
        }
        
        <!-- Step 3: Preferences -->
        @if (currentStep() === 3) {
          <div formGroupName="preferences">
            <h2>Preferences</h2>
            
            <label>
              <input type="checkbox" formControlName="newsletter" />
              Subscribe to newsletter
            </label>
            
            <fieldset>
              <legend>Theme</legend>
              <label>
                <input type="radio" formControlName="theme" value="light" />
                Light
              </label>
              <label>
                <input type="radio" formControlName="theme" value="dark" />
                Dark
              </label>
            </fieldset>
            
            <h3>Summary</h3>
            <pre>{{ form.value | json }}</pre>
          </div>
        }
        
        <div class="navigation">
          @if (currentStep() > 1) {
            <button type="button" (click)="prevStep()">Previous</button>
          }
          
          @if (currentStep() < 3) {
            <button 
              type="button" 
              (click)="nextStep()"
              [disabled]="!isCurrentStepValid()"
            >
              Next
            </button>
          } @else {
            <button type="submit" [disabled]="form.invalid">
              Complete Registration
            </button>
          }
        </div>
      </form>
    </div>
  `
})
export class RegistrationWizardComponent {
  private fb = inject(FormBuilder);
  
  currentStep = signal(1);
  
  form = this.fb.group({
    personal: this.fb.group({
      firstName: ['', Validators.required],
      lastName: ['', Validators.required],
      email: ['', [Validators.required, Validators.email]]
    }),
    account: this.fb.group({
      username: ['', [Validators.required, Validators.minLength(3)]],
      password: ['', [Validators.required, Validators.minLength(8)]],
      confirmPassword: ['', Validators.required]
    }, { validators: this.passwordMatchValidator }),
    preferences: this.fb.group({
      newsletter: [true],
      theme: ['light' as const],
      notifications: this.fb.array([])
    })
  });
  
  passwordMatchValidator(group: AbstractControl) {
    const password = group.get('password')?.value;
    const confirm = group.get('confirmPassword')?.value;
    return password === confirm ? null : { passwordMismatch: true };
  }
  
  isCurrentStepValid(): boolean {
    const stepGroups = ['personal', 'account', 'preferences'];
    const currentGroup = this.form.get(stepGroups[this.currentStep() - 1]);
    return currentGroup?.valid ?? false;
  }
  
  nextStep() {
    if (this.isCurrentStepValid() && this.currentStep() < 3) {
      this.currentStep.update(s => s + 1);
    }
  }
  
  prevStep() {
    if (this.currentStep() > 1) {
      this.currentStep.update(s => s - 1);
    }
  }
  
  onSubmit() {
    if (this.form.valid) {
      console.log('Registration data:', this.form.value);
    }
  }
}

Summary

1

Template-Driven Forms

Use FormsModule with ngModel for simple forms with two-way binding
2

Reactive Forms

Use FormControl, FormGroup, FormArray for complex, testable forms
3

Validators

Built-in validators plus custom sync/async validators
4

FormBuilder

Cleaner API for building forms with NonNullableFormBuilder for type safety
5

Dynamic Forms

Use FormArray for repeating fields and dynamic form generation

Interview Deep-Dive

Strong Answer: I would use reactive forms with FormBuilder for this, not template-driven. The reasons: multi-step wizards need programmatic control over which step is valid, dynamic fields need runtime FormGroup manipulation, and payment processing needs async validators that are easier with reactive forms.My architecture: one top-level FormGroup with nested FormGroups for each step — personal, shipping, payment. Each step component receives its FormGroup as an input and renders the fields. The parent wizard component owns the entire form and controls navigation. This separation means step components are reusable and testable in isolation.For dynamic country-based fields: when the user selects a country, I dynamically add or remove FormControls. US addresses need state and zip. UK addresses need postcode and county. I listen to the country control’s valueChanges observable and call addControl/removeControl on the address FormGroup. I also swap validators — US zip needs a 5-digit pattern, UK postcode needs a different pattern.For async validation on the address, I use an async validator that calls an address verification API with debounce (to avoid calling on every keystroke). The form shows a “validating…” state (ng-pending class) while the API call is in flight.For step navigation: each step checks its own FormGroup validity before allowing “Next.” The final step can validate the entire form at once. I persist form state to localStorage via an effect, so the user can resume if they accidentally close the tab.Follow-up: How do you handle the password field validation where confirm-password must match password? Answer: I use a cross-field validator at the FormGroup level, not on individual controls. The validator function receives the group, reads both password and confirmPassword values, and returns an error object if they do not match. This is important because the error lives on the group, not on either control — so the template needs to check formGroup.errors?.passwordMismatch, not control.errors. A common mistake is putting the validator on confirmPassword, which does not have access to the password value.
Strong Answer: Regular FormBuilder creates controls where the value type includes null and undefined. When you call form.reset(), all controls are set to null (not their initial values). So form.controls.name.value has type string | null, and you need null checks everywhere.NonNullableFormBuilder changes two things. First, reset() restores controls to their initial values instead of null. Second, the TypeScript types reflect this — form.controls.name.value is string, not string | null. This eliminates an entire category of null-check boilerplate.In practice, this matters for type safety downstream. If you pass form.getRawValue() to an API service that expects a User object (no null fields), the regular FormBuilder version will not type-check without explicit casting or null assertions. The NonNullableFormBuilder version matches the API type naturally.The gotcha: NonNullableFormBuilder does not prevent individual controls from being explicitly set to null via setValue(null). It only affects the reset behavior and the inferred types. If your form legitimately needs nullable fields (like an optional date field), you still need to declare those as FormControl<string | null>.Follow-up: How do strongly typed forms interact with dynamic FormArrays? Answer: FormArrays with typed forms require you to specify the type of controls they contain. this.fb.array<FormGroup<ItemForm>>([]) tells TypeScript that each element is a FormGroup matching the ItemForm interface. When you push or access elements, TypeScript knows the shape. The challenge is that dynamically created controls need to match the declared type exactly. If your ItemForm has required fields, you must initialize them when creating new form groups.
Strong Answer: ControlValueAccessor is the bridge between Angular’s forms API and custom UI components. Once implemented, your component works with formControlName, ngModel, validation, disabled state — all the form features. The classic use cases: star rating widgets, rich text editors, date range pickers, tag inputs, or any UI element that does not map to a native HTML input.You implement four methods: writeValue (called when the form sets the control’s value), registerOnChange (gives you a callback to call when the user changes the value), registerOnTouched (callback for when the control is blurred), and optionally setDisabledState. You also register the component as a NG_VALUE_ACCESSOR provider using forwardRef.The key insight that separates a good implementation from a fragile one: writeValue can be called at any time, including before ngOnInit. So do not assume the component is fully initialized when writeValue runs. Also, call the onChange callback only on user interaction, not in writeValue — otherwise you create infinite loops where the form sets a value, which triggers onChange, which updates the form, which calls writeValue again.I always test CVA components by using them inside a reactive form in the test, setting values programmatically, checking that the form value updates when the user interacts, and verifying that disabled state propagates correctly.Follow-up: Can you use signals inside a ControlValueAccessor implementation? Answer: Yes, and it works well. You can store the internal value as a signal, use it in the template with signal reads, and call the registered onChange callback in the methods that respond to user interaction. The writeValue method calls this.value.set(newValue). This gives you reactive templates while maintaining full form integration. Just be careful not to trigger onChange from an effect that reads the value signal, as that creates a feedback loop.

Next Steps

Next: HTTP Client & RxJS

Connect to APIs and master reactive data streams