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 model is inspectable and testable without a DOM โ
form.setValue(...), assertform.valid, unit-test validation as plain logic. - Typed forms:
NonNullableFormBuildergives the whole tree TypeScript types โform.value.emailisstring, typos in control names are compile errors, andgetRawValue()returns a typed object ready for the API. - Dynamic structure โ adding/removing controls at runtime (
FormArraybelow) is natural in code, contorted in templates. - Cross-field and async validation have first-class homes.
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
- Why does the submit handler call
markAllAsTouched()on invalid submit โ what UX rule does it implement? - A pristine form shows red โrequiredโ errors on load. Which state flags are being ignored?
- Where does โpassword and confirm must matchโ attach, and why not on either control?
- The API rejects
emailas already registered. How does that error reach the fieldโs UI? - 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.