Menu

Utiliser des formulaires à base de signaux avec Angular

Ceci n'étant possible qu'à partir de Angular 21

Les signaux avec Angular simplifient beaucoup de chose et rapprochent celui-ci de comportements observables avec d'autres frameworks.

La nouveauté ici est les formulaires à base de signaux.
La documentation à ce propos.
https://next.angular.dev/essentials/signal-forms 

Voici un exemple :

Le template HTML

<form (submit)="onRegister()">
  <div>
    <label for="username">Pseudonyme</label>
    <input
      id="username"
      autocomplete="username"
      type="text"
      fieldError
      [field]="registerForm.username"
      [fieldState]="registerForm.username"
    />
  </div>
  <div>
    <label for="email">Email</label>
    <input
      id="email"
      autocomplete="email"
      type="email"
      fieldError
      [field]="registerForm.email"
      [fieldState]="registerForm.email"
    />
  </div>
  <div>
    <button type="submit">Envoyer</button>
  </div>
</form>


Le composant

import { ChangeDetectionStrategy, Component, model, signal } from '@angular/core'
import { email, Field, FieldPath, form, maxLength, minLength, required } from '@angular/forms/signals'
import { FieldError } from '@quezap/core/directives'

function registerFormValidator(path: FieldPath<{
 email: string
 username: string
}>) {
 required(path.email, { message: 'L\'email est requis.' })
 email(path.email, { message: 'L\'email doit être valide.' })
 required(path.username, { message: 'Le nom d\'utilisateur est requis.' })
 minLength(path.username, 3, { message: 'Le nom d\'utilisateur doit faire au moins 3 caractères.' })
 maxLength(path.username, 20, { message: 'Le nom d\'utilisateur doit faire au maximum 20 caractères.' })
}

@Component({
 selector: 'quizz-register-modal',
 imports: [
   Field,
   FieldError,
 ],
 templateUrl: './register-modal.html',
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RegisterModal {
 private readonly userInfo = signal({
   email: '',
   username: '',
 })
 protected readonly registerForm = form(this.userInfo, registerFormValidator)
 
 protected onRegister() {
   if (this.registerForm().invalid()) {
   	 alert('Problème de validation')
     return
   }
   
   alert('Register')
 }
 protected onCancel() {
   this.visible.set(false)
 }
}


Les règles de validation se trouvent dans  registerFormValidator. Nous écrivons donc ces règles de manière déclaratives.
Sachez qu'il existe plusieurs règles, mais que vous pouvez définir les vôtres permettant d'accomplir vous permettant de vous simplifier la tâche.

Exemple :

function registerFormValidator(path: FieldPath<{
 email: string
 confirmEmail: string
 username: string
}>) {
 required(path.email, { message: 'L\'email est requis.' })
 email(path.email, { message: 'L\'email doit être valide.' })
 email(path.confirmEmail, { message: 'La confirmation d\'email n\'est pas valide' })
 required(path.username, { message: 'Le nom d\'utilisateur est requis.' })
 minLength(path.username, 3, { message: 'Le nom d\'utilisateur doit faire au moins 3 caractères.' })
 maxLength(path.username, 20, { message: 'Le nom d\'utilisateur doit faire au maximum 20 caractères.' })
 validate(path, ({ value }) => {
   if (value().email !== value().confirmEmail) {
     return customError({
       kind: 'mismatch',
       message: 'Les emails ne correspondent pas.',
     })
   }
   return []
 })
}

 

Enfin, pour déclarer le formulaire, nous utilisons registerForm = form(this.userInfo, registerFormValidator).
Cela facilite grandement les choses et sachez que vous avez toujours accès à des propriétés comme touched, dirty ou errors sur InputFieldState.
Vous pouvez également utiliser validateHttp  ou validateAsync pour faire une validation de manière asynchrone. Par ailleurs, disabled ou requiredfaçon acceptent d'autres signaux vous permettant d'appliquer des règles de façon conditionnelle.

Exemple de validateHttp HTTP :

  validateHttp(path.email, {
   options: {
     defaultValue: false,
     parse: (value) => {
       return Boolean(value)
     },
   },
   request: ({ value }) => {
     return value() ? `https://example.com/api/check/${value()}` : undefined
   },
   onSuccess(value, ctx) {
     const isTaken = value
     if (isTaken) {
       return [customError({
         kind: 'taken',
         message: 'Cet email est déjà utilisé.',
       })]
     }
     return []
   },
   onError: (error, ctx) => {
     return [customError({
       kind: 'server',
       message: `Erreur serveur lors de la validation de l'email`,
     })]
   },
 })


Possibilité d'utiliser des schémas Zod ce qui selon moi est une combinaison très puissante et préférable :


  protected readonly registerForm = form<FormData>(this.userInfo, (path) => {
   validateStandardSchema(path, zod.object({
     email: zod.email('Email invalide'),
     username: zod.string()
       .min(3, '3 caractères minimum')
       .max(20, '20 caractères maximum'),
   }))
 })

Ici, nous ajoutons des messages d'erreurs personnalisés, mais avec la V4 de Zod, il est possible d'avoir les messages directement en français.

import { z } from 'zod'
z.config(z.locales.fr())

 

Bonus :

Voici une directive qui peut être pratique pour gérer de manière uniforme les messages d'erreurs :

import { Directive, effect, inject, input, OnDestroy, Renderer2, ViewContainerRef } from '@angular/core'
import { FieldState, ValidationErrorWithField } from '@angular/forms/signals'

type InputFieldState = FieldState<string, string>

@Directive({
 selector: '[quizzFieldError]',
 standalone: true,
})
export class FieldError implements OnDestroy {
 readonly fieldState = input.required<() => InputFieldState>()
 
 private readonly viewContainerRef = inject(ViewContainerRef)
 private readonly renderer = inject(Renderer2)
 private readonly errorElement: HTMLElement
 
 constructor() {
   this.errorElement = this.renderer.createElement('div')
   this.renderer.setStyle(this.errorElement, 'color', 'red')
   this.renderer.setStyle(this.errorElement, 'margin-top', '0.5rem')
   this.renderer.setStyle(this.errorElement, 'font-size', '0.875rem')
   this.renderer.setStyle(this.errorElement, 'white-space', 'pre-wrap')
   this.viewContainerRef.element.nativeElement.after(this.errorElement)
   
   effect(() => {
     const errorList = this.extractErrorMessagesFrom(this.fieldState()())
     const isVisible = errorList.length !== 0
     this.renderer.setProperty(this.errorElement, 'innerText', errorList.join('\n'))
     this.renderer.setStyle(this.errorElement, 'display', isVisible ? '' : 'none')
   })
 }
 
 ngOnDestroy(): void {
   this.renderer.removeChild(this.errorElement.parentNode, this.errorElement)
 }
 
 private extractErrorMessagesFrom(input: InputFieldState): string[] {
   if (!input.touched() || !input.dirty() || input.errors().length === 0) {
     return []
   }
   
   return input.errors().map(this.getErrorMessage)
 }

 private getErrorMessage(error: ValidationErrorWithField): string {
    const field = error.field().name
    const kind = error.kind
    const message = error.message

    if (kind === 'standardSchema') {
      return (error as StandardSchemaValidationError).issue.message
    }

    return message === undefined ? `• [${field}] Erreur de type '${kind}'` : `• ${message}`
 }
lundi 10 novembre 2025