Angular in 10 steps - 6 of 10

06 - Forms: raccogliere dati senza impazzire
Quasi ogni applicazione, prima o poi, deve chiedere qualcosa all'utente: un login, una ricerca, un checkout. I form sono il punto dove i dati entrano nell'app, e infatti sono anche il punto dove si annidano più bug: input non validati, errori mostrati al momento sbagliato, submit che partono a vuoto.
Angular offre due approcci per gestirli:
- Template-driven forms: si scrive quasi tutto nell'HTML, ideali per form semplici
- Reactive forms: il modello del form vive nel TypeScript, pensati per form complessi
In questo capitolo li vediamo entrambi, con le validazioni e qualche consiglio su quando scegliere l'uno o l'altro.
🧩 Template-driven: il form nell'HTML
L'approccio template-driven si appoggia alla direttiva ngModel, che conosci
già dal capitolo sul data binding: è il two-way binding applicato ai campi di
un form. Il grosso del lavoro sta nel template, il componente resta leggero.
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('inviato:', this.nome());
14 }
15}1<form (ngSubmit)="submit()">
2 <label>
3 Nome:
4 <input name="nome" [(ngModel)]="nome" />
5 </label>
6 <p>Ciao, {{ nome() }}!</p>
7
8 <button type="submit">Invia</button>
9</form>Due dettagli che fanno inciampare tutti la prima volta:
Al two-way binding si passa il signal, non la sua chiamata: si scrive
[(ngModel)]="nome", mentre{{ nome() }}resta una lettura e vuole le parentesi. E dentro un<form>, ogni campo conngModeldeve avere l'attributoname, altrimenti Angular protesta.
Validare un form template-driven
La tentazione, dopo il capitolo sui signals, sarebbe costruirsi la validazione
a mano con un paio di computed. Si può fare, ma non serve: Angular tiene già
la contabilità di ogni campo, se è stato toccato (touched), se è cambiato
(dirty), se è valido, e la espone attraverso lo stesso ngModel. Basta
chiederla con una template reference variable:
1<form (ngSubmit)="submit()">
2 <label>
3 Nome:
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>Il campo è obbligatorio</p>
17 }
18 @if (nomeCtrl.hasError('minlength')) {
19 <p>Servono almeno 3 caratteri</p>
20 }
21 </div>
22 }
23
24 <button type="submit">Invia</button>
25</form>Cosa sta succedendo:
requiredeminlengthsono normali attributi HTML che Angular trasforma in validatori#nomeCtrl="ngModel"ci dà accesso allo stato del campo direttamente nel template- gli errori compaiono solo dopo che il campo è stato toccato: niente form rosso al primo caricamento
Per form di due o tre campi, questo approccio è onesto e veloce. Quando i campi diventano dieci, o si aggiungono e tolgono dinamicamente, il template si trasforma in un groviglio. Ed è lì che entrano in gioco i reactive forms.
🔧 Reactive forms: il form nel TypeScript
Nei reactive forms il modello del form è un oggetto che costruisci nel componente, con tre mattoni:
- FormControl: un singolo campo
- FormGroup: un gruppo di controlli, tipicamente l'intero form
- FormArray: una lista dinamica di controlli, per i campi che si aggiungono a runtime
Il template si limita a collegarsi al modello. Ecco un form di profilo con validazioni e una lista dinamica di contatti:
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 // rende visibili gli errori di tutti i campi
43 this.profiloForm.markAllAsTouched();
44 }
45 }
46}E il template:
1<form [formGroup]="profiloForm" (ngSubmit)="onSubmit()">
2 <label>
3 Nome:
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>Il nome è obbligatorio</p>
10 }
11 @if (nome?.hasError('minlength')) {
12 <p>Servono almeno 3 caratteri</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>L'email è obbligatoria</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()">Aggiungi contatto</button>
38
39 <button type="submit">Invia</button>
40</form>Nota il flusso del submit: il bottone è sempre cliccabile e, se il form non è
valido, markAllAsTouched() fa comparire tutti gli errori. È una scelta
deliberata: disabilitare il bottone con [disabled]="profiloForm.invalid" è
comodo, ma lascia l'utente davanti a un bottone spento senza spiegargli il
perché. Un click che rivela gli errori è un'esperienza più chiara.
Dai tempi di Angular 14 i reactive forms sono tipizzati: il compilatore sa che
profiloForm.value.nomeè una stringa e ti segnala l'errore se provi a leggere un campo che non esiste. Se ilnew FormGroup(...)a mano ti sembra verboso, guarda ancheFormBuilder: iniettato coninject(FormBuilder), riduce la cerimonia con la sintassifb.group({...}).
Quale approccio scegliere
La mia bussola, dopo anni di form:
- Template-driven quando il form è piccolo, statico e le validazioni sono quelle standard HTML: login, ricerca, newsletter
- Reactive quando ci sono campi dinamici, validazioni incrociate, logica condizionale o quando il form va testato per bene: il modello in TypeScript si testa senza toccare il DOM
Nel dubbio, reactive: un template-driven che cresce si riscrive, un reactive sovradimensionato al massimo ha qualche riga in più.
I validator pronti all'uso
Angular fornisce una serie di validatori già fatti, utilizzabili sia come
attributi (template-driven) sia da Validators (reactive):
Validators.required: il campo non può essere vuotoValidators.email: formato emailValidators.minLength(n)/Validators.maxLength(n): lunghezza minima e massimaValidators.min(n)/Validators.max(n): valori numericiValidators.pattern(regex): qualsiasi cosa esprimibile con una regex
Quando non bastano, puoi scrivere validatori custom: sono semplici funzioni che
ricevono il controllo e restituiscono un oggetto errori o null. Ci torneremo
quando serviranno.
Dove siamo arrivati
Ora sai raccogliere dati in entrambi i modi che Angular mette a disposizione:
template-driven con ngModel e lo stato esposto nel template, reactive con il
modello in TypeScript, le validazioni programmatiche e i campi dinamici via
FormArray. E soprattutto sai quando usare l'uno e quando l'altro.
Nel prossimo capitolo parliamo di servizi e dependency injection: dove
mettere la logica e i dati che i componenti devono condividere, a partire da
quel inject() che continua a spuntare negli esempi.