Technology  /  Angular

๐Ÿ…ฐ๏ธ Angular 15 guides ยท updated 2026

Standalone components, signals, RxJS, and dependency injection โ€” building maintainable frontends with the framework enterprises trust.

Angular Forms: Reactive vs Template-Driven, Typed Forms, and Validation

Forms are where enterprise apps live โ€” onboarding flows, claims processing, admin consoles, checkout. Theyโ€™re also where UI complexity concentrates: values, validity, dirty/touched states, cross-field rules, dynamic rows, server errors. Angular ships two complete form systems, and its reactive forms module is arguably the best complex-form tooling in any framework โ€” a genuine reason teams pick Angular.

This guide compares the two systems honestly, then goes deep on reactive forms: typed models, validation, the error-display UX that tutorials skip, and dynamic forms with FormArray.


Two Systems, One Decision

Template-driven forms put the model in the template โ€” [(ngModel)] two-way bindings with validation attributes:

<form #f="ngForm" (ngSubmit)="save(f.value)">
<input name="email" [(ngModel)]="email" required email />
<button [disabled]="f.invalid">Save</button>
</form>

Reactive forms put the model in the class โ€” an explicit object tree the template binds to:

export class SignupForm {
private fb = inject(NonNullableFormBuilder);
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(10)]],
acceptTerms: [false, Validators.requiredTrue],
});
submit() {
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
this.api.signup(this.form.getRawValue()).subscribe(...);
}
}
<form [formGroup]="form" (ngSubmit)="submit()">
<input formControlName="email" type="email" />
<input formControlName="password" type="password" />
<label><input formControlName="acceptTerms" type="checkbox" /> I accept</label>
<button>Create account</button>
</form>

The trade is explicitness for power. Template forms are quicker for trivial cases (a search box, a feedback field); reactive forms win everywhere complexity lives, for reasons that compound:

The working rule most teams adopt: reactive by default; template-driven only for genuinely trivial inputs. This series follows it.


Validation: Built-Ins, Custom, and Cross-Field

Validators are functions attached to controls; state updates continuously:

Validators.required, Validators.email, Validators.min(1),
Validators.maxLength(80), Validators.pattern(/^C-\d{6}$/)

Custom validators are just functions โ€” (control) => null | errorObject:

export function paiseAmount(max: number): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const v = Number(control.value);
if (!Number.isInteger(v) || v <= 0) return { paiseAmount: 'must be a positive integer' };
if (v > max) return { paiseAmount: `max ${max}` };
return null; // null = valid
};
}
// usage: ['', [Validators.required, paiseAmount(10_00_000)]]

Pure functions, boundary-validation logic with names, trivially unit-tested. The error objectโ€™s keys become the vocabulary templates check.

Cross-field rules attach to the group, since no single control owns them:

form = this.fb.group(
{ password: ['', Validators.required], confirm: ['', Validators.required] },
{ validators: (g) =>
g.value.password === g.value.confirm ? null : { mismatch: true } },
);

Async validators (third argument) return a Promise/Observable โ€” the username-availability check โ€” and set the controlโ€™s pending state while in flight; debounce the underlying API call, as with any keystroke-driven request.


Error Display: The UX Layer Tutorials Skip

Validation state is easy; validation experience is the craft. The core mechanism is the touched/dirty model: controls track touched (blurred once) and dirty (edited), so you can avoid the cardinal sin โ€” screaming โ€œREQUIRED!โ€ at a pristine empty form:

<input formControlName="email" type="email"
[class.invalid]="showError('email')" />
@if (showError('email')) {
<p class="field-error">
@if (form.controls.email.hasError('required')) { Email is required. }
@else if (form.controls.email.hasError('email')) { That doesn't look like an email. }
</p>
}
showError(name: keyof typeof this.form.controls) {
const c = this.form.controls[name];
return c.invalid && (c.touched || this.submitted());
}

The pattern in words โ€” the consensus UX across mature products: errors appear after blur or submit attempt, never during first typing; submit attempts reveal everything (markAllAsTouched() in the invalid-submit branch โ€” thatโ€™s why itโ€™s there). Refinements worth adopting: per-error messages (not a generic โ€œinvalidโ€), a disabled-or-guarded submit plus the reveal (never a silently dead button โ€” users deserve to know why), and server-side errors mapped back onto controls via setErrors when the API rejects a field โ€” the backend is the real validator; the form is its UX.

Since the boilerplate above repeats per field, real codebases extract a field-wrapper component or directive that renders label + input + errors uniformly โ€” a perfect content-projection exercise, and the reason large appsโ€™ forms look consistent.


Dynamic Forms: FormArray

The invoice-line-items problem โ€” user-controlled repetition โ€” is where reactive forms lap the field:

form = this.fb.group({
customer: ['', Validators.required],
lines: this.fb.array([this.makeLine()]),
});
get lines() { return this.form.controls.lines; }
makeLine() {
return this.fb.group({
description: ['', Validators.required],
qty: [1, [Validators.required, Validators.min(1)]],
unitPaise: [0, paiseAmount(10_00_00_000)],
});
}
addLine() { this.lines.push(this.makeLine()); }
removeLine(i: number) { this.lines.removeAt(i); }
<div formArrayName="lines">
@for (line of lines.controls; track line; let i = $index) {
<div [formGroupName]="i" class="line-row">
<input formControlName="description" />
<input formControlName="qty" type="number" />
<input formControlName="unitPaise" type="number" />
<button type="button" (click)="removeLine(i)">โœ•</button>
</div>
}
</div>
<button type="button" (click)="addLine()">Add line</button>

Each row is a full FormGroup with its own validation; the arrayโ€™s validity rolls up into the formโ€™s. Totals derive naturally (computed over form.value exposed as a signal โ€” toSignal(this.form.valueChanges) bridges until fully signal-native forms land). Wizards, conditional sections (addControl/removeControl on step changes), and rule-builder UIs are all this pattern scaled up โ€” try expressing any of them in template-driven forms and the decision framework becomes visceral.

Two mechanics that round out production readiness: patchValue (partial) vs setValue (complete, strict) for populating edit forms from API data, and disable()/enable() on controls โ€” disabled controls drop out of value but appear in getRawValue(), a distinction that has surprised everyone exactly once.


Wiring Forms to the Server: The Full Round Trip

Real forms bracket an API call on both ends, and the edit-form round trip deserves its own walkthrough because every CRUD app repeats it:

export class EditProductPage {
private fb = inject(NonNullableFormBuilder);
private api = inject(ProductApi);
id = input.required<string>(); // from the route
form = this.fb.group({
name: ['', Validators.required],
pricePaise: [0, [Validators.required, paiseAmount(10_00_00_000)]],
description: [''],
});
saving = signal(false);
constructor() {
effect(() => { // populate when data arrives
const p = this.product.value();
if (p) this.form.patchValue(p);
});
}
product = httpResource<Product>(() => `/api/products/${this.id()}`);
save() {
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
this.saving.set(true);
this.api.update(this.id(), this.form.getRawValue()).subscribe({
next: () => { this.saving.set(false); this.router.navigate(['/products', this.id()]); },
error: (err) => {
this.saving.set(false);
this.applyServerErrors(err); // field errors back onto controls
},
});
}
private applyServerErrors(err: HttpErrorResponse) {
const fieldErrors = err.error?.fields ?? {};
for (const [name, message] of Object.entries(fieldErrors)) {
this.form.get(name)?.setErrors({ server: message });
}
}
}

The checkpoints that distinguish production forms: patchValue (not setValue) tolerates API responses carrying extra fields; the in-flight signal gates the submit button against double-submission; server field errors land on their controls with a generic server key the error-display layer renders like any validatorโ€™s message; and navigation happens only on confirmed success. One deliberate judgment call in the template layer: while saving, disable rather than hide the form โ€” users lose trust in forms that vanish and reappear. The dirty-check bonus: form.dirty after patchValue tells you whether anything actually changed โ€” the input for both a smart โ€œdiscard changes?โ€ canDeactivate guard and skipping no-op saves.

Frequently Asked Questions

Which system does [(ngModel)] belong to โ€” and can I mix them? Template-driven (FormsModule). Mixing both on the same control is an error and a design smell; mixing across the app (reactive for real forms, an ngModel on a standalone search box) is fine and common.

How do signals change forms? Today: interop via toSignal(form.valueChanges) for derivations, and signal-based disabled/visibility logic around the form. A fully signal-native forms API is the announced direction โ€” the concepts here (explicit model, validators as functions, touched/dirty UX) all carry forward.

Where does form state live for multi-step wizards? A component-provided service owning the form (or per-step groups) โ€” steps become presentational children reading their slice, and the wizard survives step navigation because the service outlives each step component.

valueChanges โ€” what is it and when do I care? An Observable of the formโ€™s value over time โ€” the hook for autosave (debounced), dependent-field logic (โ€œstateโ€ options loading when โ€œcountryโ€ changes โ€” switchMap territory), and dirty-tracking beyond the built-in flag.

What about accessibility in forms? The non-negotiables: every input gets a real <label for> (placeholder text is not a label โ€” it vanishes on input); error messages are programmatically associated (aria-describedby pointing at the error element) so screen readers announce them; and on failed submit, move focus to the first invalid control โ€” the markAllAsTouched moment is also the focus-management moment. The semantic-elements rule applies doubly here: forms are where accessibility failures lock real users out of real tasks.

How do I test a form component? Two layers: the form logic (validators, structure) as plain objects โ€” no DOM; the UX (errors appear on blur, submit disabled) via component tests driving inputs. The reactive model makes the first layer possible โ€” template-driven forms only have the second.


Five Form Mistakes That Survive Code Review

1. Validating only on the client. The formโ€™s rules are UX; the APIโ€™s rules are law. Any mismatch surfaces as a server rejection your UI must handle gracefully โ€” which is why the server-error-mapping pattern above isnโ€™t optional polish.

2. form.value where getRawValue() was meant. Disabled controls vanish from value โ€” the edit form that silently drops the read-only id field on save is this bug wearing production clothes.

3. Binding [disabled] on a formControlName input. Angular warns for a reason: template-disabled and model-disabled fight each other. Disabled state belongs to the control (ctrl.disable()), where validity logic sees it too.

4. Nested subscriptions to valueChanges re-setting other controls โ€” the reactive-forms version of effect-derived state: easy to create loops ({ emitEvent: false } is the tell youโ€™re already in one). Derive display values with computeds; reserve control-writes for genuine dependencies (country โ†’ states), one direction only.

5. One mega-form for a wizard. A single 40-control group makes per-step validity contorted. Per-step groups composed into a parent (or held in a wizard service) let each step own its rules, with stepGroup.valid gating navigation naturally.

Self-Check

  1. Why does the submit handler call markAllAsTouched() on invalid submit โ€” what UX rule does it implement?
  2. A pristine form shows red โ€œrequiredโ€ errors on load. Which state flags are being ignored?
  3. Where does โ€œpassword and confirm must matchโ€ attach, and why not on either control?
  4. The API rejects email as already registered. How does that error reach the fieldโ€™s UI?
  5. Sketch the model (groups/arrays/controls) for: shipping address + one-or-more parcels, each with weight and dimensions.

(Answers: it reveals all field errors at the moment the user asked to proceed โ€” errors after intent, not before. touched/dirty โ€” error display must be gated on them (or a submit flag). The group โ€” the rule involves two controls; neither alone can know validity. form.controls.email.setErrors({ taken: true }) in the API error handler, rendered like any other error key. A group({ address: group({...}), parcels: array(group({ weightKg, l, w, h })) }) โ€” with min-length 1 validation on the array.)

A last calibration on the two-system choice, because teams occasionally relitigate it: the Angular team maintains both systems deliberately, and template-driven forms are not deprecated โ€” theyโ€™re scoped. If your form is a single input with no validation choreography (newsletter signup, comment box), [(ngModel)] is honestly less code and reads fine. The moment any of this guideโ€™s machinery becomes relevant โ€” typed values, cross-field rules, dynamic rows, server-error mapping, testable validation โ€” youโ€™ve crossed into reactive territory, and hybrid half-migrations cost more than committing. Most teams encode exactly that line in their style guide and stop debating it, which is the correct amount of energy for the question.

Core Angular complete. The advanced section opens with the system underneath everything youโ€™ve used: Signals and State Management โ€” how fine-grained reactivity works, and how to structure app state with it.