Aller au contenu principal

Signal Formulaires (Angular 21)

Qu'est-ce que les Signal Forms ?

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.

Pourquoi utiliser les Signal Forms ?

  • 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

Signal Forms vs Reactive Forms

FonctionnalitéReactive Forms (v16)Signal Forms (v21)
APIFormGroup, FormControlform(), Field
ÉtatBasé sur les observablesBasé sur les signaux
ValidateursClasse ValidatorsFonctions (required, min, validate)
Binding templateformControlName[field]
Sécurité des typesPartielleComplète
BoilerplateÉlevéFaible
Courbe d'apprentissageRaideDouce

Utilisation de base

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);
}
}
}

Template Binding

Field Directive

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

Accessing Field State

<!-- 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>
}

Built-in Validators

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>
}
}

Custom Validators

Synchronous Validators

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()));
}
);
}

Async Validators

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

Form State

Accessing Form State

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'
}
}

Field-Level State

// 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();

Managing Dynamic Fields (Tags)

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);
}
}
}

CanDeactivate Guard avec les Signal Forms

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...
}
}
}

Exemple complet

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']);
});
}
}
}

Bonnes Pratiques

  • ✅ 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

Migration depuis Reactive Forms

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>


Project Reference

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