Front-end angularforall.com

- Angular Signal Forms : validation moderne

Angular Signal-Forms Reactive-Forms Validation Formulaires Angular-21 Signals Schema Validateasync Typescript Form-Array Tutoriel
Angular Signal Forms : validation moderne

Découvrez Signal Forms d'Angular 21 : API signal-based, validation déclarative, schémas réutilisables et migration depuis Reactive Forms avec exemples.

Pourquoi Signal Forms ?

Les Reactive Forms d'Angular existent depuis 2016 et restent solides. Pourquoi alors une nouvelle API ? La réponse tient en trois mots : signaux, typage et déclaratif. Reactive Forms repose sur RxJS (valueChanges, statusChanges) et impose des subscriptions manuelles, des FormBuilder verbeux, et un typage limité (le passage à FormGroup<T> en Angular 14 a aidé mais reste imparfait).

Signal Forms, introduit en developer preview avec Angular 19 et stabilisé en Angular 21, propose :

  • Lecture synchrone via Signal : form.value(), form.valid(), form.dirty() sans async pipe ni subscribe.
  • Typage end-to-end : le schéma déclaratif infère les types automatiquement, du modèle au template.
  • Validation déclarative : les règles sont des fonctions pures lisant des Signals — réévaluation automatique.
  • Schémas réutilisables : un même schéma peut alimenter plusieurs formulaires (création/édition).
  • Compatible zoneless : aucune dépendance à Zone.js dans le pipeline de validation.
État de l'API : Signal Forms est preview en Angular 19/20 (paquet @angular/forms/signals) et stable en Angular 21+. Avant la v21, attendez-vous à des changements d'API mineurs entre versions.

Installation et premier formulaire

Signal Forms fait partie du package @angular/forms à partir d'Angular 21. Pas de dépendance supplémentaire à installer. Pour activer l'API, on importe les helpers depuis @angular/forms/signals.

// app.config.ts — provider standard
import { ApplicationConfig } from '@angular/core';
import { provideForms } from '@angular/forms/signals';

export const appConfig: ApplicationConfig = {
    providers: [
        // Active les directives Signal Forms globalement
        provideForms()
    ]
};

Premier formulaire — modèle utilisateur :

// user-form.component.ts
import { Component, signal } from '@angular/core';
import { form, validate, required, minLength, email } from '@angular/forms/signals';

interface UserModel {
    firstName: string;
    lastName: string;
    email: string;
    age: number;
}

@Component({
    selector: 'app-user-form',
    standalone: true,
    template: `
        <form (ngSubmit)="submit()">
            <label>
                Prénom
                <input [control]="userForm.firstName" />
                @if (userForm.firstName.errors().length) {
                    <span class="error">{{ userForm.firstName.errors()[0].message }}</span>
                }
            </label>

            <label>
                Email
                <input type="email" [control]="userForm.email" />
            </label>

            <button type="submit" [disabled]="!userForm.valid()">Envoyer</button>
        </form>
    `
})
export class UserFormComponent {
    // Modèle initial — Signal modifiable
    private model = signal<UserModel>({
        firstName: '',
        lastName: '',
        email: '',
        age: 0
    });

    // Création du formulaire avec schéma de validation
    readonly userForm = form(this.model, (path) => {
        // Règles déclaratives champ par champ
        validate(path.firstName, required(), minLength(2));
        validate(path.lastName, required());
        validate(path.email, required(), email());
        validate(path.age, (v) => v >= 18 ? null : { code: 'minAge', message: 'Vous devez avoir 18 ans' });
    });

    submit() {
        if (this.userForm.valid()) {
            // value() retourne le modèle complet typé
            console.log('Données soumises :', this.userForm.value());
        }
    }
}

Les points clés :

  • Le modèle de départ est un simple signal(). Pas besoin de FormBuilder ni d'instancier des contrôles à la main.
  • form(model, schema) retourne un objet typé qui mappe chaque clé du modèle à un contrôle exposant value(), errors(), dirty(), touched(), valid().
  • La directive [control]="userForm.firstName" remplace l'ancienne formControlName.
Pas d'Object literal : contrairement à Reactive Forms où FormBuilder.group({...}) imbrique les contrôles dans un objet runtime, Signal Forms construit un proxy typé statiquement à partir du modèle. Le coût mémoire est inférieur car aucun FormControl n'est instancié pour chaque champ.

Validation déclarative et schémas

La validation Signal Forms est fonctionnelle : chaque validateur est une fonction pure qui reçoit la valeur courante et retourne null (valide) ou un objet d'erreur. Pas de classe, pas de provider, pas de boilerplate.

Validators built-in disponibles :

Validator Usage Code d'erreur
required()Champ obligatoirerequired
minLength(n)Longueur min string/arrayminLength
maxLength(n)Longueur maxmaxLength
min(n)Valeur min numériquemin
max(n)Valeur max numériquemax
pattern(regex)Regex matchpattern
email()Format email RFCemail

Validation conditionnelle (cross-field) :

interface PasswordModel {
    password: string;
    confirm: string;
}

readonly passwordForm = form(this.model, (path) => {
    validate(path.password, required(), minLength(8));
    validate(path.confirm, required());

    // Validation cross-field : on lit deux champs
    // Le validateur reçoit le formulaire complet en deuxième argument
    validate(path.confirm, (confirm, ctx) => {
        // ctx.root() expose le modèle complet en lecture
        const expected = ctx.root().password;
        return confirm === expected
            ? null
            : { code: 'mismatch', message: 'Les mots de passe ne correspondent pas' };
    });
});

Schéma réutilisable — extraction dans un fichier dédié :

// schemas/user.schema.ts
import { Schema, validate, required, email, minLength } from '@angular/forms/signals';
import { UserModel } from '../models/user.model';

export const userSchema: Schema<UserModel> = (path) => {
    validate(path.firstName, required(), minLength(2));
    validate(path.lastName, required(), minLength(2));
    validate(path.email, required(), email());
    validate(path.age, (v) => v >= 0 ? null : { code: 'positive', message: 'Âge positif requis' });
};

// Réutilisation dans plusieurs composants
// create-user.component.ts
this.form = form(this.emptyModel, userSchema);
// edit-user.component.ts
this.form = form(this.existingUserModel, userSchema);
Bénéfice clé : les schémas sont des fonctions pures, donc testables unitairement sans monter Angular. Vous pouvez tester userSchema avec Vitest sans TestBed.

Validation conditionnelle avec champ activable :

interface ProfileModel {
    notificationEnabled: boolean;
    email: string;
}

readonly profileForm = form(this.model, (path) => {
    // L'email n'est requis que si les notifications sont activées
    validate(path.email, (value, ctx) => {
        const enabled = ctx.root().notificationEnabled;
        if (!enabled) return null; // skip validation
        if (!value) return { code: 'required', message: 'Email obligatoire si notifications activées' };
        return null;
    });
});

Validation asynchrone avec resource()

Pour les validations qui nécessitent un appel serveur (vérifier qu'un username est disponible, par exemple), Signal Forms s'intègre avec resource() et httpResource() d'Angular 19+.

import { resource } from '@angular/core';
import { form, validateAsync } from '@angular/forms/signals';

@Component({ /* ... */ })
export class SignupComponent {
    private http = inject(HttpClient);
    private model = signal({ username: '', email: '' });

    readonly form = form(this.model, (path) => {
        // Validation synchrone d'abord
        validate(path.username, required(), minLength(3));

        // Puis validation asynchrone côté serveur
        validateAsync(path.username, async (value) => {
            // Annulation automatique si l'utilisateur retape entre-temps
            const available = await firstValueFrom(
                this.http.get<{ available: boolean }>(`/api/check-username?u=${value}`)
            );
            return available.available
                ? null
                : { code: 'taken', message: 'Username déjà pris' };
        }, { debounce: 300 });
    });

    // pending() expose l'état de chargement async
    isCheckingUsername = computed(() => this.form.username.pending());
}

Points importants :

  • validateAsync accepte une option debounce pour limiter les appels HTTP.
  • Les requêtes précédentes sont automatiquement annulées (AbortController sous le capot).
  • form.username.pending() permet d'afficher un spinner pendant la vérification.
  • L'état valid() reste false tant que la validation async n'est pas terminée.
UX recommandée : afficher l'erreur uniquement quand le champ est touched et que pending() est false. Sinon vous fatiguez l'utilisateur avec des erreurs intermittentes.

Formulaires imbriqués et FormArray

Signal Forms gère naturellement les structures imbriquées et les listes dynamiques sans recourir à un FormArray spécifique.

Objet imbriqué :

interface ContactModel {
    name: string;
    address: {
        street: string;
        city: string;
        zip: string;
    };
}

readonly contactForm = form(this.model, (path) => {
    validate(path.name, required());
    // Accès direct aux propriétés imbriquées via le path proxy
    validate(path.address.street, required());
    validate(path.address.city, required());
    validate(path.address.zip, pattern(/^\d{5}$/));
});

// Template
// <input [control]="contactForm.address.street" />

Tableau dynamique (équivalent FormArray) :

interface InvoiceModel {
    customer: string;
    items: Array<{ label: string; quantity: number; price: number }>;
}

private model = signal<InvoiceModel>({
    customer: '',
    items: [{ label: '', quantity: 1, price: 0 }]
});

readonly invoiceForm = form(this.model, (path) => {
    validate(path.customer, required());
    // applyEach applique le schéma à chaque élément du tableau
    applyEach(path.items, (item) => {
        validate(item.label, required());
        validate(item.quantity, min(1));
        validate(item.price, min(0));
    });
});

addItem() {
    // Mutation immutable du Signal
    this.model.update(m => ({
        ...m,
        items: [...m.items, { label: '', quantity: 1, price: 0 }]
    }));
}

removeItem(index: number) {
    this.model.update(m => ({
        ...m,
        items: m.items.filter((_, i) => i !== index)
    }));
}

Template avec @for :

@for (item of invoiceForm.items.controls(); track item.id; let i = $index) {
    <div class="invoice-row">
        <input [control]="item.label" placeholder="Article" />
        <input [control]="item.quantity" type="number" />
        <input [control]="item.price" type="number" />
        <button type="button" (click)="removeItem(i)">Retirer</button>
    </div>
}
<button type="button" (click)="addItem()">Ajouter une ligne</button>
Comparaison avec Reactive Forms : ajout/suppression d'éléments d'un FormArray Reactive Forms exigeait this.itemsFA.push(this.fb.group({...})). Avec Signal Forms, on mute le modèle source et le formulaire reflète automatiquement la nouvelle structure.

Migrer depuis Reactive Forms

La migration n'est pas urgente — Reactive Forms reste pleinement supporté. Mais si vous démarrez un nouveau formulaire complexe, Signal Forms réduit significativement le code. Voici les étapes pour migrer un formulaire existant.

Avant — Reactive Forms typé :

// Reactive Forms (Angular 14+)
this.form = this.fb.nonNullable.group({
    title: ['', [Validators.required, Validators.maxLength(100)]],
    body: ['', Validators.required],
    tags: this.fb.array<string>([])
});

// Souscription manuelle pour réagir aux changements
this.form.controls.title.valueChanges.pipe(
    distinctUntilChanged(),
    takeUntilDestroyed()
).subscribe(t => this.titleSlug.set(slugify(t)));

// Soumission
submit() {
    if (this.form.valid) {
        const value = this.form.getRawValue();
        this.api.save(value);
    }
}

Après — Signal Forms :

// Signal Forms (Angular 21+)
private model = signal({
    title: '',
    body: '',
    tags: [] as string[]
});

readonly form = form(this.model, (path) => {
    validate(path.title, required(), maxLength(100));
    validate(path.body, required());
});

// Réaction aux changements via computed (pas de subscribe)
readonly titleSlug = computed(() => slugify(this.form.title.value()));

submit() {
    if (this.form.valid()) {
        // value() est typé automatiquement
        this.api.save(this.form.value());
    }
}
  • Définir le modèle TypeScript explicite (interface)
  • Convertir FormBuilder.group en signal() + form()
  • Remplacer Validators.X par les helpers required(), minLength(), email()
  • Convertir valueChanges.subscribe en computed() ou effect()
  • Remplacer formControlName par [control] dans les templates
  • Supprimer takeUntilDestroyed (plus nécessaire avec Signals)

Tester un Signal Form

Tester un Signal Form est plus simple que tester un Reactive Form — on peut souvent se passer du TestBed et tester le schéma comme une fonction pure.

// user-form.spec.ts (Vitest)
import { describe, it, expect } from 'vitest';
import { signal } from '@angular/core';
import { form } from '@angular/forms/signals';
import { userSchema } from './schemas/user.schema';

describe('userSchema', () => {
    it('rejette un email invalide', () => {
        const model = signal({ firstName: 'Jean', lastName: 'Dupont', email: 'pas-un-email', age: 30 });
        const f = form(model, userSchema);

        expect(f.valid()).toBe(false);
        expect(f.email.errors()[0].code).toBe('email');
    });

    it('accepte un modèle valide', () => {
        const model = signal({ firstName: 'Jean', lastName: 'Dupont', email: 'jean@test.com', age: 30 });
        const f = form(model, userSchema);

        expect(f.valid()).toBe(true);
        expect(f.email.errors()).toHaveLength(0);
    });

    it('met à jour la validation quand le modèle change', () => {
        const model = signal({ firstName: '', lastName: '', email: '', age: 0 });
        const f = form(model, userSchema);

        expect(f.valid()).toBe(false);

        // Mise à jour du modèle — validation réévaluée automatiquement
        model.set({ firstName: 'A', lastName: 'B', email: 'a@b.fr', age: 25 });
        expect(f.firstName.errors()[0].code).toBe('minLength');
    });
});

Pour les tests d'intégration avec template, le TestBed reste nécessaire — mais avec moins de boilerplate qu'auparavant car il n'y a plus besoin d'importer ReactiveFormsModule.

Bonnes pratiques en production

1. Garder le modèle plat tant que possible

Plus le modèle est plat, plus le path proxy est simple à lire. Imbriquez seulement quand le domaine l'impose (adresse, contact).

2. Externaliser les schémas complexes

Un schéma de 30 lignes dans un composant rend la lecture pénible. Extrayez dans schemas/*.schema.ts et exportez une Schema<Model>.

3. Préférer computed à effect

Pour les valeurs dérivées (slug, total HT, prévisualisation), utilisez computed. effect doit être réservé aux véritables effets de bord (logger, analytics, persistance).

4. Afficher les erreurs au bon moment

// Composant utilitaire pour afficher les erreurs
@if (control.touched() && control.errors().length) {
    <span class="error">{{ control.errors()[0].message }}</span>
}

Ne jamais afficher d'erreur tant que l'utilisateur n'a pas interagi avec le champ (touched) — sinon le formulaire est rouge dès le premier rendu.

5. Standardiser les codes d'erreur

Définissez un dictionnaire central de codes d'erreur côté front + i18n. { code: 'username.taken' } traduit en "Ce nom d'utilisateur est déjà pris" via $localize ou ngx-translate.

6. Mesurer la performance

Les Signal Forms réduisent typiquement la charge de change detection sur les gros formulaires (50+ champs). Mesurez avec Angular DevTools Profiler avant/après migration sur les formulaires critiques (commande, profil).

Pour aller plus loin : consultez la documentation officielle angular.dev/guide/signals/forms et l'article de référence sur blog.angulartraining.com.

Conclusion : faut-il migrer dès maintenant ?

Signal Forms représente la réécriture la plus profonde de la gestion de formulaires depuis Angular 2. L'API signal-based remplace les FormControl/FormGroup par des structures réactives natives, alignées sur le reste du framework moderne (Signals, Standalone, control flow @if/@for). En Angular 21+, c'est l'API recommandée pour tout nouveau formulaire.

À retenir avant d'adopter Signal Forms :

  • Démarrer sur du neuf : créer les nouveaux formulaires en Signal Forms, garder les Reactive Forms existants jusqu'à refactor planifié.
  • Schémas réutilisables : factoriser les validations métier dans des fonctions schema() partagées entre composants.
  • Validation async = resource() : tirer parti de l'intégration native pour vérifier email/username sans debounceTime manuel.
  • Tests simplifiés : un Signal Form se teste comme un objet — plus besoin de FormBuilder ni de fakeAsync.
  • Migration progressive : composant par composant, sans big-bang ni gel des features.
Pour aller plus loin : combinez Signal Forms avec l'API resource() pour les validations serveur, et la migration RxJS → Signals pour aligner toute votre couche réactive.

Partager