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.
<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>
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].
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 },
];
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] : [],
}),
])
)
);
}
<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.
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 viafor/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>.
<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:
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
Build a ControlValueAccessor-based tag input
A reusable "add tags" input, wired fully into the forms system.
Requirements:
- Create
TagInput, implementingControlValueAccessorwith an internalsignal<string[]>value. - A text field where pressing Enter adds the current text as a new tag (and clears the field); clicking a tag removes it.
- Call
onChangeandonTouchedat the right moments so the parent form sees accuratevalue,dirty, andtouchedstate. - Implement
setDisabledStateso the component respects the parent form disabling it (hide the add/remove controls when disabled). - Use it inside a reactive form with a custom validator requiring at least one tag, and confirm the parent's
form.invalidreflects the tag input's state correctly. - 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.
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 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?
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?
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?
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.