Week 13: Template-Driven & Dynamic Forms

The final week of Phase 3 rounds out your forms knowledge: the simpler template-driven approach, generating forms from data instead of hand-writing every field, and building custom inputs that plug into either form style seamlessly.

Phase 3 of 7 Week 13 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Choose the right form strategy for a given feature
  • Build a custom form control that works inside a reactive form
  • Write a meaningful test for a form's validation behavior

1. Template-Driven Forms & ngModel

Template-driven forms let the template itself define the form's structure — ngModel handles two-way binding and Angular builds the underlying form model for you, invisibly.

quick-feedback.html
<form #feedbackForm="ngForm" (ngSubmit)="onSubmit(feedbackForm.value)">
  <input
    name="email"
    [(ngModel)]="email"
    required
    email
    #emailField="ngModel"
  />
  @if (emailField.invalid && emailField.touched) {
    <p class="error">Enter a valid email.</p>
  }

  <button type="submit" [disabled]="feedbackForm.invalid">Send</button>
</form>
quick-feedback.ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-quick-feedback',
  templateUrl: './quick-feedback.html',
  imports: [FormsModule],
})
export class QuickFeedback {
  email = '';

  onSubmit(value: unknown) {
    console.log(value);
  }
}

Notice there's no FormGroup anywhere in the TypeScript — the entire form structure lives in the template, driven by required, email, and the template reference variables (#emailField="ngModel") you met back in Week 4.

2. When Template-Driven Beats Reactive (and Vice Versa)

A genuine tradeoff, not a "reactive is always better" situation:

  • Template-driven fits: a short, simple form (one newsletter signup field, a quick feedback box) where the template-only structure keeps the component trivially small.
  • Reactive fits: anything with cross-field validation, dynamic controls, async validation, or logic you want to unit test without rendering a template — everything Weeks 12 and this lesson's exercise cover.

In practice, most production teams default to reactive forms for anything beyond the simplest case, precisely because the form's validity logic becomes testable TypeScript instead of template-only behavior you can only verify by rendering the component.

3. Building a Schema-Driven Dynamic Form Renderer

Sometimes a form's fields aren't known until runtime — an admin building custom intake forms, for example. The pattern: describe fields as data, then render controls from that data with @for and [formControlName].

form-schema.ts
export interface FieldSchema {
  key: string;
  label: string;
  type: 'text' | 'email' | 'number';
  required: boolean;
}

export const schema: FieldSchema[] = [
  { key: 'fullName', label: 'Full name', type: 'text', required: true },
  { key: 'email', label: 'Email', type: 'email', required: true },
  { key: 'age', label: 'Age', type: 'number', required: false },
];
dynamic-form.ts
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { schema, FieldSchema } from './form-schema';

export class DynamicForm {
  fields: FieldSchema[] = schema;

  form = new FormGroup(
    Object.fromEntries(
      this.fields.map((field) => [
        field.key,
        new FormControl('', {
          nonNullable: true,
          validators: field.required ? [Validators.required] : [],
        }),
      ])
    )
  );
}
dynamic-form.html
<form [formGroup]="form">
  @for (field of fields; track field.key) {
    <label>
      {{ field.label }}
      <input [formControlName]="field.key" [type]="field.type" />
    </label>
  }
</form>

The type of form.value here is necessarily looser than Week 12's fully typed forms — since the shape comes from runtime data, not a compile-time literal — a reasonable tradeoff for the flexibility this pattern buys you.

4. Custom Form Control Components (ControlValueAccessor)

ControlValueAccessor is the interface that lets a custom component plug into formControlName/ngModel exactly like a native <input> does — Angular's forms system doesn't know or care that your component isn't a native form element.

star-rating-input.ts
import { Component, forwardRef, signal } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'app-star-rating-input',
  templateUrl: './star-rating-input.html',
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => StarRatingInput),
      multi: true,
    },
  ],
})
export class StarRatingInput implements ControlValueAccessor {
  protected value = signal(0);
  protected disabled = signal(false);

  private onChange: (value: number) => void = () => {};
  private onTouched: () => void = () => {};

  writeValue(value: number): void {
    this.value.set(value ?? 0);
  }

  registerOnChange(fn: (value: number) => void): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }

  setDisabledState(isDisabled: boolean): void {
    this.disabled.set(isDisabled);
  }

  select(stars: number) {
    if (this.disabled()) return;
    this.value.set(stars);
    this.onChange(stars);
    this.onTouched();
  }
}

Four methods, each with one job: writeValue receives updates from the form (e.g. patchValue), registerOnChange/registerOnTouched capture callbacks you call when the user interacts, and setDisabledState reacts to the form disabling the control. Once implemented, this component drops straight into a reactive or template-driven form with no special handling — recognizable from Week 4's RatingStars, now wired into the forms system properly.

5. Form Accessibility

A handful of practices matter more for forms than almost anywhere else in an app:

  • Every input needs a real <label>, associated via for/id — a placeholder is not a label, and disappears the moment the user starts typing.
  • Error messages should be programmatically associated with their field via aria-describedby, not just visually nearby.
  • Use aria-invalid="true" on a field with an active error, so assistive technology announces the invalid state.
  • Group related fields (like Week 12's phone-numbers array) with <fieldset> and <legend>.
accessible-field.html
<label for="email">Email</label>
<input
  id="email"
  formControlName="email"
  [attr.aria-invalid]="form.controls.email.invalid && form.controls.email.touched"
  aria-describedby="email-error"
/>
@if (form.controls.email.invalid && form.controls.email.touched) {
  <p id="email-error" class="error">Enter a valid email address.</p>
}

You'll run a full accessibility audit in Week 22 — this section is the forms-specific subset worth building as habit now, since retrofitting it across many forms later is far more work than writing it correctly the first time.

6. Testing Forms

A reactive form's validity logic can be tested without rendering any template at all — directly instantiate the FormGroup and assert on it:

signup-form.spec.ts
import { SignupForm } from './signup-form';

describe('SignupForm validation', () => {
  it('is invalid with a mismatched password confirmation', () => {
    const component = new SignupForm();

    component.form.patchValue({
      password: 'Sup3rSecret',
      confirmPassword: 'somethingElse',
    });

    expect(component.form.errors?.['passwordsMismatch']).toBeTrue();
  });

  it('is valid with matching, strong passwords', () => {
    const component = new SignupForm();

    component.form.patchValue({
      password: 'Sup3rSecret',
      confirmPassword: 'Sup3rSecret',
    });

    expect(component.form.valid).toBeTrue();
  });
});

This is the payoff promised back in Section 2 — because the form's validity rules live in plain TypeScript, testing them is just testing TypeScript, no TestBed or rendered DOM required.

7. Hands-on Exercise

Hands-on

Build a ControlValueAccessor-based tag input

A reusable "add tags" input, wired fully into the forms system.

Requirements:

  1. Create TagInput, implementing ControlValueAccessor with an internal signal<string[]> value.
  2. A text field where pressing Enter adds the current text as a new tag (and clears the field); clicking a tag removes it.
  3. Call onChange and onTouched at the right moments so the parent form sees accurate value, dirty, and touched state.
  4. Implement setDisabledState so the component respects the parent form disabling it (hide the add/remove controls when disabled).
  5. Use it inside a reactive form with a custom validator requiring at least one tag, and confirm the parent's form.invalid reflects the tag input's state correctly.
  6. Write at least one test instantiating the parent form directly (Section 6's pattern) and asserting on its validity with zero and with one tag.
Hint

If the parent form's value never updates when you add a tag, double-check you're actually calling the stored onChange callback after updating the internal signal — updating the signal alone only changes what your component sees, not what the parent form knows about.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

In a template-driven form, where does the form's structure actually get defined?

In the template itself — ngModel, required, and similar directives on each input, rather than a FormGroup built in TypeScript. Angular constructs the underlying form model automatically by reading the template, which is exactly why it's a poor fit once validation logic gets complex enough to want testing independent of rendering.

Q2

What's the actual benefit of building a form from a FieldSchema[] array instead of hand-writing each control?

The set of fields doesn't need to be known at compile time — it can come from an API response, a CMS, or user configuration, and the same rendering code handles whatever schema arrives. The tradeoff is weaker compile-time typing on the resulting form's value, since the shape isn't a fixed literal the compiler can check.

Q3

What does writeValue() do in ControlValueAccessor, and how is it different from registerOnChange()'s callback?

writeValue() handles the form-to-component direction — Angular calls it whenever the form's value is set programmatically (e.g. patchValue), and your component updates its own display accordingly. The onChange callback registered via registerOnChange() is the reverse direction — your component calls it when the user changes the value, telling the form about the update. Mixing these up is the most common bug when building a custom control.

Q4

Why can the password-mismatch test in Section 6 run without TestBed or rendering any template?

Because the form's validation logic is plain TypeScript objects and functions (FormGroup, ValidatorFn) with no dependency on Angular's rendering pipeline or dependency injection — it can be instantiated with new directly, exactly like any other plain class. Only code that actually needs Angular's injector, template compilation, or lifecycle hooks requires TestBed.