Aller au contenu

Signal Formulaires (Angular 21)

Signal Forms est une nouvelle API de formulaires réactifs introduite dans Angular 21 qui remplace FormGroup, FormControl et FormBuilder par une approche basée sur les signaux. Ils offrent une manière plus simple et plus sûre au niveau des types pour gérer les formulaires.

  • ✅ Basé sur les signaux - Entièrement réactif avec les signaux Angular
  • ✅ Type-safe - Meilleure inférence TypeScript
  • ✅ API plus simple - Pas de boilerplate FormGroup/FormControl
  • ✅ Déclaratif - Définir le formulaire et les validateurs ensemble
  • ✅ Meilleure DX - Code plus propre et plus lisible
  • ✅ Validation intégrée - Validateurs synchrones et asynchrones
Fonctionnalité Reactive Forms (v16) Signal Forms (v21)
API FormGroup, FormControl form(), Field
État Basé sur les observables Basé sur les signaux
Validateurs Classe Validators Fonctions (required, min, validate)
Binding template formControlName [field]
Sécurité des types Partielle Complète
Boilerplate Élevé Faible
Courbe d’apprentissage Raide Douce

import { Component, signal } from '@angular/core';
import { form, Field, required, min } from '@angular/forms/signals';
import { Album } from '../models/album.model';
@Component({
selector: 'app-album-form',
standalone: true,
imports: [Field],
template: `
<form>
<label>
Album Name:
<input [field]="albumForm.name">
</label>
@for (error of albumForm.name().errors(); track error.kind) {
@if (error.kind === 'required') {
<p class="error">Album name is required</p>
}
}
<label>
Price:
<input [field]="albumForm.price" type="number">
</label>
@for (error of albumForm.price().errors(); track error.kind) {
@if (error.kind === 'required') {
<p class="error">Price is required</p>
}
@if (error.kind === 'min') {
<p class="error">Price must be at least 0</p>
}
}
<button
(click)="saveAlbum()"
[disabled]="!albumForm().valid()">
Save Album
</button>
</form>
`
})
export class AlbumFormComponent {
// Créer un formulaire signal avec les valeurs initiales et les validateurs
albumForm = form(
signal<Album>({
id: 0,
name: '',
artist: '',
description: '',
price: 0,
tags: []
}),
(path) => {
// Appliquer les validateurs
required(path.name);
required(path.artist);
required(path.price);
min(path.price, 0);
}
);
saveAlbum() {
if (this.albumForm().valid()) {
const albumData = this.albumForm().value();
console.log('Saving album:', albumData);
}
}
}

Utiliser la directive [field] pour lier les inputs aux champs du formulaire :

<input [field]="albumForm.name">
<input [field]="albumForm.price" type="number">
<textarea [field]="albumForm.description"></textarea>

La directive [field] :

  • Lie automatiquement la valeur
  • Gère les événements de changement
  • Met à jour l’état du formulaire
  • Fournit les signaux d’erreur
<!-- Field value -->
<p>Current name: {{ albumForm.name().value() }}</p>
<!-- Field errors -->
@for (error of albumForm.name().errors(); track error.kind) {
<p class="error">{{ error.message }}</p>
}
<!-- Field validity -->
@if (albumForm.name().valid()) {
<p>✓ Valid</p>
}
<!-- Field dirty state -->
@if (albumForm.name().dirty()) {
<p>Field has been modified</p>
}
<!-- État touched du champ -->
@if (albumForm.name().touched()) {
<p>Field has been focused</p>
}

import { form, required } from '@angular/forms/signals';
albumForm = form(
signal({ name: '', artist: '' }),
(path) => {
required(path.name);
required(path.artist);
}
);
@for (error of albumForm.name().errors(); track error.kind) {
@if (error.kind === 'required') {
<p class="error">Album name is required</p>
}
}

import { form, required, validate } from '@angular/forms/signals';
// Fonction de validateur personnalisé
function priceEndsWithNine(value: number) {
if (!value) return null;
const lastDigit = value.toString().charAt(value.toString().length - 1);
return lastDigit !== '9'
? { kind: 'priceEndingWith9', message: 'Price must end with 9' }
: null;
}
@Component({...})
export class AlbumFormComponent {
albumForm = form(
signal({ name: '', price: 0 }),
(path) => {
required(path.name);
required(path.price);
// Appliquer le validateur personnalisé
validate(path.price, (ctx) => priceEndsWithNine(ctx.value()));
}
);
}

Utiliser validateAsync pour la validation côté serveur :

@Injectable({ providedIn: 'root' })
export class AlbumService {
http = inject(HttpClient);
// HTTP Resource for async validation
albumByArtistAndNameResource() {
return httpResource<Album[]>(() => {
return 'http://localhost:3000/albums';
}, { defaultValue: [] });
}
save(album: Album): Observable<Album> {
return this.http.post<Album>('http://localhost:3000/albums', album);
}
}

Pattern validateAsync :

  1. params - Extraire les valeurs à valider
  2. factory - Ressource HTTP ou observable
  3. onSuccess - Retourner une erreur ou undefined
  4. onError - Gérer les erreurs HTTP

export class AlbumFormComponent {
albumForm = form(
signal({ name: '', price: 0 }),
(path) => {
required(path.name);
min(path.price, 0);
}
);
checkFormState() {
// Get form value
const value = this.albumForm().value();
console.log('Form value:', value);
// Check validity
const isValid = this.albumForm().valid();
console.log('Is valid:', isValid);
// Check dirty state
const isDirty = this.albumForm().dirty();
console.log('Is dirty:', isDirty);
// Get errors
const errors = this.albumForm().errors();
console.log('Form errors:', errors);
// Get status
const status = this.albumForm().status();
// 'VALID' | 'INVALID' | 'PENDING' | 'DISABLED'
}
}
// Get field value
const name = this.albumForm.name().value();
// Check field validity
const isNameValid = this.albumForm.name().valid();
// Check if field is dirty
const isNameDirty = this.albumForm.name().dirty();
// Vérifier si le champ a été touché
const isNameTouched = this.albumForm.name().touched();
// Get field errors
const nameErrors = this.albumForm.name().errors();

Les Signal Forms n’ont pas encore d’équivalent à FormArray. Utiliser des signaux séparés pour les champs dynamiques :

import { Component, signal } from '@angular/core';
import { form, Field, required } from '@angular/forms/signals';
@Component({
selector: 'app-album-form',
standalone: true,
imports: [Field],
template: `
<form>
<input [field]="albumForm.name" placeholder="Album name">
<div class="tags-section">
<h3>Tags</h3>
@for (tag of tags(); track $index; let i = $index) {
<div class="tag-item">
<input
[value]="tag"
(input)="updateTag(i, $any($event.target).value)"
placeholder="Tag {{ i + 1 }}">
<button type="button" (click)="removeTag(i)">
Remove
</button>
</div>
}
<button type="button" (click)="addTag()">
Add Tag
</button>
</div>
<button
(click)="saveAlbum()"
[disabled]="!albumForm().valid()">
Save
</button>
</form>
`
})
export class AlbumFormComponent {
// Main form
albumForm = form(
signal({ id: 0, name: '', artist: '', price: 0 }),
(path) => {
required(path.name);
required(path.artist);
}
);
// Separate signal for tags
tags = signal<string[]>([]);
addTag() {
this.tags.update(tags => [...tags, '']);
}
removeTag(index: number) {
this.tags.update(tags => tags.filter((_, i) => i !== index));
}
updateTag(index: number, value: string) {
this.tags.update(tags => {
const newTags = [...tags];
newTags[index] = value;
return newTags;
});
}
saveAlbum() {
if (this.albumForm().valid()) {
const albumData = {
...this.albumForm().value(),
tags: this.tags()
};
console.log('Saving:', albumData);
}
}
}

Empêcher la navigation avec des changements non sauvegardés :

import { Injectable } from '@angular/core';
import { CanDeactivate } from '@angular/router';
import { Observable, of } from 'rxjs';
export interface CanComponentDeactivate {
canDeactivate: () => Observable<boolean> | boolean;
}
@Injectable({ providedIn: 'root' })
export class FormDeactivateGuard implements CanDeactivate<CanComponentDeactivate> {
canDeactivate(component: CanComponentDeactivate): Observable<boolean> | boolean {
return component.canDeactivate();
}
}
@Component({...})
export class AlbumFormComponent implements CanComponentDeactivate {
private dialog = inject(MatDialog);
private formSubmitted = signal(false);
albumForm = form(
signal({ name: '', artist: '', price: 0 }),
(path) => {
required(path.name);
}
);
canDeactivate(): Observable<boolean> {
// Allow navigation if form not dirty or already submitted
if (!this.albumForm().dirty() || this.formSubmitted()) {
return of(true);
}
// Show confirmation dialog
return this.dialog.open(ConfirmDialogComponent, {
data: {
title: 'Unsaved Changes',
message: 'You have unsaved changes. Do you really want to leave?'
}
}).afterClosed();
}
saveAlbum() {
if (this.albumForm().valid()) {
this.formSubmitted.set(true);
// Save logic...
}
}
}

Real-world form from step-18:

import { Component, inject, signal } from '@angular/core';
import { Field, form, min, required, validate, validateAsync } from '@angular/forms/signals';
import { AlbumService } from './services/album.service';
import { Album } from './models/album.model';
function priceEndsWithNine(value: number) {
if (!value) return null;
const lastDigit = value.toString().charAt(value.toString().length - 1);
return lastDigit !== '9'
? { kind: 'priceEndingWith9', message: 'Price must end with 9' }
: null;
}
@Component({
selector: 'app-album-form',
standalone: true,
imports: [Field],
template: `
<h2>Add Album (Signal Forms)</h2>
@if (hasAlbumExistsError()) {
<div class="error-banner">
⚠️ Album already exists
</div>
}
<form>
<label>
Album Name:
<input [field]="albumForm.name">
@for (error of albumForm.name().errors(); track error.kind) {
@if (error.kind === 'required') {
<span class="error">Required</span>
}
}
</label>
<label>
Artist:
<input [field]="albumForm.artist">
@for (error of albumForm.artist().errors(); track error.kind) {
@if (error.kind === 'required') {
<span class="error">Required</span>
}
}
</label>
<label>
Price:
<input [field]="albumForm.price" type="number">
@for (error of albumForm.price().errors(); track error.kind) {
@if (error.kind === 'required') {
<span class="error">Required</span>
}
@if (error.kind === 'min') {
<span class="error">Must be at least 0</span>
}
@if (error.kind === 'priceEndingWith9') {
<span class="error">{{ error.message }}</span>
}
}
</label>
<label>
Description:
<textarea [field]="albumForm.description"></textarea>
</label>
<div class="tags-section">
<h3>Tags</h3>
@for (tag of tags(); track $index; let i = $index) {
<div class="tag-item">
<input
[value]="tag"
(input)="updateTag(i, $any($event.target).value)">
<button type="button" (click)="removeTag(i)">Remove</button>
</div>
}
<button type="button" (click)="addTag()">Add Tag</button>
</div>
<button
type="button"
(click)="saveAlbum()"
[disabled]="!albumForm().valid()">
Save Album
</button>
</form>
`
})
export class AlbumFormComponent {
private albumService = inject(AlbumService);
private router = inject(Router);
private formSubmitted = signal(false);
tags = signal<string[]>([]);
albumForm = form(
signal<Album>({
id: 0,
name: '',
artist: '',
description: '',
price: 0,
tags: []
}),
(path) => {
// Validateurs obligatoires
required(path.name);
required(path.artist);
required(path.price);
min(path.price, 0);
// Validateur synchrone personnalisé
validate(path.price, (ctx) => priceEndsWithNine(ctx.value()));
// Validateur asynchrone
validateAsync(path, {
params: (ctx) => ctx.value(),
factory: () => this.albumService.albumByArtistAndNameResource(),
onSuccess: (albums) => {
const { artist, name } = this.albumForm().value();
if (!artist || !name || !albums) return undefined;
const exists = albums.some((a: Album) =>
a.name.toLowerCase() === name.toLowerCase() &&
a.artist.toLowerCase() === artist.toLowerCase()
);
return exists
? { kind: 'albumExists', message: 'Album already exists' }
: undefined;
},
onError: () => ({
kind: 'albumCheckFailed',
message: 'Could not verify album'
})
});
}
);
hasAlbumExistsError() {
return this.albumForm().errors().some((e: any) => e.kind === 'albumExists');
}
addTag() {
this.tags.update(tags => [...tags, '']);
}
removeTag(index: number) {
this.tags.update(tags => tags.filter((_, i) => i !== index));
}
updateTag(index: number, value: string) {
this.tags.update(tags => {
const newTags = [...tags];
newTags[index] = value;
return newTags;
});
}
saveAlbum() {
if (this.albumForm().valid()) {
this.formSubmitted.set(true);
const albumData = {
...this.albumForm().value(),
tags: this.tags()
} as Album;
this.albumService.save(albumData).subscribe(() => {
this.router.navigate(['/albums']);
});
}
}
}

  • ✅ Utiliser form() au lieu de FormGroup/FormBuilder
  • ✅ Appliquer les validateurs dans le callback path
  • ✅ Utiliser la directive [field] pour le binding du template
  • ✅ Accéder au formulaire en tant que signal : albumForm() et non albumForm
  • ✅ Utiliser des signaux séparés pour les tableaux dynamiques (pas encore de FormArray)
  • ✅ Combiner la valeur du formulaire avec d’autres signaux lors de la soumission
  • ✅ Utiliser validateAsync avec httpResource pour la validation serveur
  • ✅ Vérifier dirty() avant les avertissements de navigation
  • ❌ Ne pas mélanger avec Reactive Forms dans le même composant
  • ❌ Ne pas oublier d’importer la directive Field

import { FormBuilder, Validators } from '@angular/forms';
export class AlbumFormComponent {
fb = inject(FormBuilder);
albumForm = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
artist: ['', Validators.required],
price: [0, [Validators.required, Validators.min(0)]]
});
get name() {
return this.albumForm.get('name');
}
saveAlbum() {
if (this.albumForm.valid) {
const album = this.albumForm.value;
// Save...
}
}
}
<form [formGroup]="albumForm">
<input formControlName="name">
<div *ngIf="name?.errors?.['required']">
Name is required
</div>
</form>


See this pattern in action:

  • Component: src/app/components/albums/album-add-signal/album-add-signal.component.ts
  • Template: src/app/components/albums/album-add-signal/album-add-signal.component.html
  • Learning Path: Day 3, Module 3.7 - Signal Forms

Last Updated: December 2024 Angular Version: 21+ Status: New API, recommended for new projects