Signal Forms sunt noul sistem de formulare din Angular, construit pe semnale. Au apărut experimental în v21 și au devenit API public stabil în v22. Ideea centrală: pornești de la un model (un semnal cu datele tale), iar form() construiește peste el un arbore de câmpuri cu stare (valid, touched, errors...) și reguli definite într-o schemă.
| Reactive Forms | Signal Forms | |
|---|---|---|
| Sursa de adevăr | FormGroup (copie internă) | modelul tău (signal) |
| Structura | construită manual (FormControl, FormGroup) | dedusă din tipul modelului |
| Validare | validatori per control | schemă: required(p.email), validate(...) |
| Reactivitate | valueChanges (Observable) | semnale (value(), valid()) |
| Binding | formControlName | [formField] |
| Tipare | bune (typed forms, v14+) | complete, din model |
Primul formular
import { Component, signal } from '@angular/core';
import { FormField, FormRoot, email, form, minLength, required } from '@angular/forms/signals';
interface Login {
email: string;
password: string;
}
@Component({
selector: 'app-login',
imports: [FormField, FormRoot],
template: `
<form [formRoot]="loginForm">
<input type="email" [formField]="loginForm.email" />
@if (loginForm.email().touched() && loginForm.email().invalid()) {
@for (e of loginForm.email().errors(); track e.kind) { <small>{{ e.message }}</small> }
}
<input type="password" [formField]="loginForm.password" />
<button type="submit" [disabled]="loginForm().submitting()">Intră</button>
</form>
`,
})
export class LoginForm {
// 1. MODELUL: un semnal obișnuit — sursa de adevăr
readonly model = signal<Login>({ email: '', password: '' });
// 2. FORMULARUL: un arbore de câmpuri peste model + SCHEMA de reguli
readonly loginForm = form(
this.model,
(path) => {
required(path.email, { message: 'Emailul e obligatoriu' });
email(path.email, { message: 'Email invalid' });
required(path.password);
minLength(path.password, 8, { message: 'Minim 8 caractere' });
},
{ submission: { action: async (f) => this.auth.login(f().value()) } },
);
}Modelul ESTE formularul
// Modelul și formularul sunt ACELAȘI lucru — nu există o copie internă
this.model.set({ email: 'ana@atlas.dev', password: '' }); // → input-ul se actualizează
this.loginForm.email().value.set('x@y.ro'); // → model() se actualizează
// Asta înseamnă:
// - încarci date de la server cu model.set(...)
// - poți deriva orice cu computed(() => this.model().email)
// - nu mai ai patchValue / setValue / valueChangesStarea unui câmp
// Fiecare nod din arbore e o funcție: îl apelezi și primești starea (semnale)
const email = this.loginForm.email(); // FieldState<string>
email.value(); // WritableSignal<string> — legat direct de model
email.value.set('a@b.ro');
email.valid(); email.invalid(); email.pending();
email.touched(); email.dirty();
email.errors(); // ValidationError[] — { kind, message }
email.getError('email');
email.disabled(); email.hidden(); email.readonly(); email.required();
email.markAsTouched();
email.focus(); // v21.1: focus pe controlul legat
// Formularul întreg e și el un câmp
this.loginForm().valid();
this.loginForm().submitting();
this.loginForm().errorSummary(); // toate erorile din arbore
this.loginForm().reset();Când arăți erorile
Un câmp poate fi invalid mult înainte ca utilizatorul să termine cu el: ana@ nu e încă un email. Starea câmpului îți dă tot ce îți trebuie ca să alegi momentul: dirty() (valoarea s-a schimbat), touched() (utilizatorul a ieșit din câmp) și valid().
dirty() && invalid(): mesajul apare de la prima tastă.
| password | ||
|---|---|---|
dirty() | false | false |
touched() | false | false |
valid() | false | false |
| eroare pe ecran | false | false |
- Taste apăsate
- 0
- Taste cu o eroare pe ecran
- 0
Să arăți erori cât timp utilizatorul scrie (dirty) înseamnă să-l cerți pentru un cuvânt pe care nu l-a terminat. Să aștepți touched e varianta obișnuită: primul verdict vine când iese din câmp, iar de acolo mesajul se actualizează live, deci dispare în clipa în care valoarea e corectă. Așteptarea până la submit merge pentru formulare scurte; o încercare de submit marchează toate câmpurile ca atinse. Orice ai alege, pune regula într-un singur loc — o componentă ca <app-field-errors> — nu în fiecare template.
Validatori
import { email, max, maxLength, min, minLength, pattern, required, validate } from '@angular/forms/signals';
form(this.model, (p) => {
required(p.name, { message: 'Obligatoriu' });
minLength(p.name, 2);
maxLength(p.bio, 280);
email(p.email);
min(p.age, 18, { message: 'Minim 18 ani' });
max(p.age, 120);
pattern(p.zip, /^\d{6}$/, { message: 'Cod poștal din 6 cifre' });
required(p.terms); // pe boolean: false = invalid (ca un checkbox nativ)
// condiționat
required(p.company, { when: ({ valueOf }) => valueOf(p.type) === 'business' });
// custom, între câmpuri
validate(p.confirm, ({ value, valueOf }) =>
value() !== valueOf(p.password) ? { kind: 'mismatch', message: 'Parolele nu coincid' } : undefined,
);
});Trimiterea formularului
// Varianta 1: submission în opțiunile form() + <form [formRoot]>
readonly f = form(this.model, schemaFn, {
submission: {
action: async (field) => {
try {
await this.api.register(field().value());
return undefined; // succes
} catch {
// erori de server legate de un câmp anume
return { kind: 'taken', message: 'Email deja folosit', fieldTree: field.email };
}
},
onInvalid: (field) => field().errorSummary()[0]?.fieldTree().focus(), // focus pe prima eroare
},
});
// Varianta 2: submit() manual
async save() {
await submit(this.f, async (field) => this.api.save(field().value()));
}Ce poți lega cu [formField]
<!-- [formField] merge pe toate controalele native: -->
<input [formField]="f.name" /> <!-- string -->
<input type="number" [formField]="f.age" /> <!-- number | null -->
<input type="checkbox" [formField]="f.terms" /> <!-- boolean -->
<input type="date" [formField]="f.birthday" /> <!-- string / Date -->
<select [formField]="f.country">...</select>
<textarea [formField]="f.bio"></textarea>
<input type="radio" value="a" [formField]="f.plan" />
<!-- și pe controale custom: FormValueControl / FormCheckboxControl / ControlValueAccessor -->
<app-rating-control [formField]="f.rating" />Câmpurile condiționale, listele dinamice și validarea asincronă sunt la Signal Forms — avansat; propriile controale, la Controale custom de formular.
Când DA, când NU
- Orice formular nou într-o aplicație v22.
- Formulare cu multă logică condiționată (câmpuri ascunse, dezactivate, validări care depind de alte câmpuri).
- Când vrei ca datele să fie un semnal simplu, ușor de derivat și de salvat.
- Aplicație mare cu sute de formulare reactive care funcționează — migrează treptat, nu tot deodată.
- Librării de componente care suportă doar
ControlValueAccessor+ Reactive Forms (verifică interop-ul CVA). - Echipa are nevoie de
valueChangesca Observable peste tot (deșitoObservablerezolvă).
Capcane frecvente
- Uiți să apelezi câmpul:
f.email.valid()nu există; corect ef.email().valid()(întâi starea, apoi semnalul). - Erori afișate imediat: arată-le doar după
touched()(sau după submit). Submit-ul marchează automat totul ca touched. - Mutezi modelul:
model().email = 'x'nu notifică. Foloseștemodel.update()sauf.email().value.set(). min/maxcu string-uri: din v22 acceptă doar numere (breaking).