Almost every application eventually needs to ask users for something: a login, a search, a checkout. Forms are where data enters the app, and also where many bugs lurk: unvalidated inputs, errors shown at the wrong moment, empty submissions.
Angular offers two approaches:
- Template-driven forms: almost everything is written in HTML, ideal for simple forms.
- Reactive forms: the form model lives in TypeScript, designed for complex forms.
In this chapter, we cover both, including validation and advice on choosing between them.
🧩 Template-driven: the form in HTML
The template-driven approach relies on the ngModel directive, already familiar from data binding: two-way binding applied to form fields. Most of the work happens in the template, keeping the component light.
1import { Component, signal } from '@angular/core';
2import { FormsModule } from '@angular/forms';
3
4@Component({
5 selector: 'app-iscrizione',
6 imports: [FormsModule],
7 templateUrl: './iscrizione.component.html'
8})
9export class IscrizioneComponent {
10 nome = signal('');
11
12 submit() {
13 console.log('submitted:', this.nome());
14 }
15}1<form (ngSubmit)="submit()">
2 <label>
3 Name:
4 <input name="nome" [(ngModel)]="nome" />
5 </label>
6 <p>Hello, {{ nome() }}!</p>
7
8 <button type="submit">Submit</button>
9</form>Two details trip everyone up the first time:
Pass the signal itself to two-way binding, not the result of calling it: write
[(ngModel)]="nome", while{{ nome() }}remains a read and needs parentheses. And inside a<form>, every field usingngModelmust have anameattribute, or Angular complains.
Validating a template-driven form
After the signals chapter, you might be tempted to build validation manually with a couple of computed signals. You can, but there is no need: Angular already tracks each field's state, whether it was touched (touched), changed (dirty), and whether it is valid, exposing that through ngModel. Just request it using a template reference variable:
1<form (ngSubmit)="submit()">
2 <label>
3 Name:
4 <input
5 name="nome"
6 [(ngModel)]="nome"
7 required
8 minlength="3"
9 #nomeCtrl="ngModel"
10 />
11 </label>
12
13 @if (nomeCtrl.touched && nomeCtrl.invalid) {
14 <div class="errore">
15 @if (nomeCtrl.hasError('required')) {
16 <p>This field is required</p>
17 }
18 @if (nomeCtrl.hasError('minlength')) {
19 <p>At least 3 characters are required</p>
20 }
21 </div>
22 }
23
24 <button type="submit">Submit</button>
25</form>What is happening:
requiredandminlengthare ordinary HTML attributes Angular turns into validators.#nomeCtrl="ngModel"gives us direct access to the field's state in the template.- Errors appear only after the field is touched: no red form on first load.
For two or three fields, this approach is straightforward and quick. When there are ten fields, or fields are dynamically added and removed, the template becomes tangled. That is where reactive forms enter the picture.
🔧 Reactive forms: the form in TypeScript
In reactive forms, the form model is an object you construct in the component using three building blocks:
- FormControl: a single field.
- FormGroup: a group of controls, typically the entire form.
- FormArray: a dynamic list of controls, for fields added at runtime.
The template simply connects to the model. Here is a profile form with validation and a dynamic contact list:
1import { Component } from '@angular/core';
2import {
3 FormArray,
4 FormControl,
5 FormGroup,
6 ReactiveFormsModule,
7 Validators,
8} from '@angular/forms';
9
10@Component({
11 selector: 'app-profilo',
12 imports: [ReactiveFormsModule],
13 templateUrl: './profilo.component.html'
14})
15export class ProfiloComponent {
16 profiloForm = new FormGroup({
17 nome: new FormControl('', [Validators.required, Validators.minLength(3)]),
18 email: new FormControl('', [Validators.required, Validators.email]),
19 contatti: new FormArray([new FormControl('', Validators.email)]),
20 });
21
22 get nome() {
23 return this.profiloForm.get('nome');
24 }
25
26 get email() {
27 return this.profiloForm.get('email');
28 }
29
30 get contatti() {
31 return this.profiloForm.get('contatti') as FormArray;
32 }
33
34 aggiungiContatto() {
35 this.contatti.push(new FormControl('', Validators.email));
36 }
37
38 onSubmit() {
39 if (this.profiloForm.valid) {
40 console.log(this.profiloForm.value);
41 } else {
42 // reveals errors for every field
43 this.profiloForm.markAllAsTouched();
44 }
45 }
46}And its template:
1<form [formGroup]="profiloForm" (ngSubmit)="onSubmit()">
2 <label>
3 Name:
4 <input type="text" formControlName="nome" />
5 </label>
6 @if (nome?.touched && nome?.invalid) {
7 <div class="errore">
8 @if (nome?.hasError('required')) {
9 <p>Name is required</p>
10 }
11 @if (nome?.hasError('minlength')) {
12 <p>At least 3 characters are required</p>
13 }
14 </div>
15 }
16
17 <label>
18 Email:
19 <input type="email" formControlName="email" />
20 </label>
21 @if (email?.touched && email?.invalid) {
22 <div class="errore">
23 @if (email?.hasError('required')) {
24 <p>Email is required</p>
25 }
26 @if (email?.hasError('email')) {
27 <p>Il formato non è valido</p>
28 }
29 </div>
30 }
31
32 <div formArrayName="contatti">
33 @for (contatto of contatti.controls; track $index) {
34 <input [formControlName]="$index" placeholder="Contatto {{ $index + 1 }}" />
35 }
36 </div>
37 <button type="button" (click)="aggiungiContatto()">Add contact</button>
38
39 <button type="submit">Submit</button>
40</form>Notice the submission flow: the button is always clickable, and if the form is invalid, markAllAsTouched() reveals every error. This is deliberate: disabling the button with [disabled]="profiloForm.invalid" is convenient, but leaves users facing an inactive button with no explanation. A click that reveals errors is a clearer experience.
Since Angular 14, reactive forms are typed: the compiler knows
profiloForm.value.nomeis a string and reports an error if you try to read a nonexistent field. If writingnew FormGroup(...)manually feels verbose, look atFormBuilder: injected throughinject(FormBuilder), it reduces ceremony withfb.group({...}).
Which approach should you choose?
My compass after years of forms:
- Template-driven for small, static forms with standard HTML validation: login, search, newsletter.
- Reactive for dynamic fields, cross-field validation, conditional logic or thorough testing: you can test the TypeScript model without touching the DOM.
When in doubt, reactive: a growing template-driven form needs rewriting; an oversized reactive form merely has a few extra lines.
Ready-to-use validators
Angular provides built-in validators, usable as attributes (template-driven) or through Validators (reactive):
Validators.required: the field cannot be empty.Validators.email: email format.Validators.minLength(n)/Validators.maxLength(n): minimum and maximum length.Validators.min(n)/Validators.max(n): numeric values.Validators.pattern(regex): anything expressible with a regular expression.
When these are insufficient, write custom validators: simple functions receiving a control and returning an error object or null. We will revisit them when needed.
Where we are now
You can now collect data using both Angular approaches: template-driven with ngModel and state exposed in the template, and reactive with a TypeScript model, programmatic validation and dynamic fields via FormArray. Above all, you know when to use each.
In the next chapter, we discuss services and dependency injection: where to put logic and data shared by components, starting with that inject() that keeps appearing in examples.
