📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~8 min de lecture

Formulaire de création et d'édition : un composant ou deux ?

Tu as un écran pour créer un utilisateur, un écran pour le modifier. Les deux affichent les mêmes champs, les mêmes validations, le même bouton ou presque. Et là, la question qui revient sur toutes les revues de code : on duplique, ou on factorise dans un seul composant avec un mode: 'create' | 'edit' ?

Les deux réponses naïves sont mauvaises. Dupliquer, c'est maintenir deux fois la même validation jusqu'au jour où elles divergent en silence. Tout fusionner dans un composant piloté par un mode, c'est ouvrir la porte aux if (mode === 'edit') qui se reproduisent comme des lapins dans le template et la logique.

La bonne réponse tient en une phrase, et on va la démontrer avec Signal Forms, stable depuis Angular 22 :

Le formulaire ne devrait pas savoir s'il crée ou modifie. Il reçoit une valeur, il émet un payload.


1. Le vrai problème : tu mélanges deux choses qui n'ont rien à voir

Quand tu compares ton écran de création et ton écran d'édition, tu vois deux blocs qui se ressemblent. Mais ils se ressemblent sur une seule des deux dimensions du formulaire.

Un écran de formulaire, c'est toujours deux responsabilités empilées :

  • La forme : quels champs, quel layout, quelles validations, quel rendu des erreurs. C'est ce qui est commun entre create et edit.
  • L'orchestration : d'où viennent les données initiales, quel endpoint on appelle au submit, où on redirige après, quel wording sur le titre et le bouton, quelles permissions. C'est ce qui diverge.

Le piège, c'est de regarder uniquement la forme (identique) et d'en conclure "un seul composant". Sauf que la divergence est dans l'orchestration, et elle te suivra partout si tu la laisses entrer dans le formulaire.

La règle devient évidente : on partage la forme, on sépare l'orchestration. Un formulaire présentationnel réutilisé tel quel, et deux conteneurs qui l'orchestrent différemment.


2. Signal Forms en 30 secondes pour être au point pour la suite

Avant le pattern, deux rappels sur l'API : la stabilisation v22 a renommé des choses, et la moitié des exemples qui traînent sont déjà périmés.

Un formulaire part d'un modèle qui est un WritableSignal. Tu décris les validations dans une fonction de schéma qui reçoit des chemins :

import { form, required, email, minLength } from '@angular/forms/signals';

const model = signal({ firstName: '', email: '', password: '' });

const f = form(model, (path) => {
  required(path.firstName, { message: 'Prénom requis' });
  required(path.email, { message: 'Email requis' });
  email(path.email, { message: 'Email invalide' });
  minLength(path.password, 8, { message: '8 caractères minimum' });
});

Chaque champ est une fonction qui rend son état. Tu lis f.email() pour récupérer le FieldState, puis ses signals :

f.email().value();    // WritableSignal<string>
f.email().errors();   // Signal<ValidationError[]>  -> { kind, message? }
f.email().touched();  // Signal<boolean>
f.email().valid();    // Signal<boolean>
f().valid();          // l'état de validité du formulaire entier

Et côté template, le binding se fait avec la directive FormField, pas avec un quelconque [control] (ça, c'était le nom de la phase expérimentale, il est mort) :

<form [formRoot]="f" (submit)="onSubmit($event)">
  <input id="email" type="email" [formField]="f.email" />
</form>

Pourquoi ça change le découpage create/edit ? Parce que le modèle est un signal. Créer, c'est partir d'un signal vide. Modifier, c'est partir d'un signal pré-rempli. Toute la différence entre les deux modes se résume à la valeur de départ de ce signal. Plus de débat reset() contre patchValue() : tu poses la valeur initiale et c'est fini.


3. Le pattern : un formulaire présentationnel, deux conteneurs

D'abord, deux types. Le serveur connaît un User avec son id ; le formulaire n'édite que les champs saisissables. Nommer cette différence évite de trimballer l'id serveur jusque dans le présentationnel :

// user.model.ts
export type User = {
  id: string;
  firstName: string;
  lastName: string;
  email: string;
};

// Ce que le formulaire édite et émet. Pas d'id : le serveur l'attribue en create,
// l'URL le porte en update.
export type UserPayload = Omit<User, 'id'>;

Le formulaire (il ne sait rien)

Il reçoit une valeur initiale, il émet un payload au submit. Aucun inject de service HTTP, aucun routing, aucun mot "create" ou "edit" nulle part.

import { form, required, email, FormField, FormRoot } from '@angular/forms/signals';

const EMPTY_USER: UserPayload = { firstName: '', lastName: '', email: '' };

@Component({
  selector: 'app-user-form',
  imports: [FormField, FormRoot],
  templateUrl: './user-form.html',
})
export class UserForm {
  readonly initialValue = input<UserPayload>(EMPTY_USER);
  readonly pending = input(false);
  readonly submitLabel = input('Enregistrer');
  readonly save = output<UserPayload>();

  // linkedSignal : writable (le form le mute) ET re-dérivé quand initialValue change
  protected readonly model = linkedSignal(() => this.initialValue());

  protected readonly f = form(this.model, (path) => {
    required(path.firstName, { message: 'Prénom requis' });
    required(path.email, { message: 'Email requis' });
    email(path.email, { message: 'Email invalide' });
  });

  protected onSubmit(event: Event): void {
    event.preventDefault();
    if (this.f().valid()) {
      this.save.emit(this.model());
    }
  }
}

Tu remarques l'absence de changeDetection: ChangeDetectionStrategy.OnPush ? Normal : depuis Angular 22, OnPush est la stratégie par défaut. L'ancien Default (CheckAlways) est renommé Eager et déprécié. Plus besoin de l'écrire sur chaque composant.

Le détail qui fait tout : linkedSignal(() => this.initialValue()). C'est un signal writable (le binding du formulaire écrit dedans quand l'utilisateur tape) qui se réinitialise tout seul quand initialValue change. On y revient dans les pièges : c'est ce qui règle le bug le plus courant du pattern.

Le template du formulaire, lui, ne parle que de champs et d'erreurs :

<form [formRoot]="f" (submit)="onSubmit($event)">
  <label for="firstName">Prénom</label>
  <input id="firstName" [formField]="f.firstName" />
  @if (f.firstName().touched()) {
    @for (error of f.firstName().errors(); track error.kind) {
      <p class="error" role="alert">{{ error.message }}</p>
    }
  }

  <label for="email">Email</label>
  <input id="email" type="email" [formField]="f.email" aria-describedby="email-error" />
  @if (f.email().touched()) {
    @for (error of f.email().errors(); track error.kind) {
      <p id="email-error" class="error" role="alert">{{ error.message }}</p>
    }
  }

  <button type="submit" [disabled]="pending() || f().invalid()">
    {{ submitLabel() }}
  </button>
</form>

Le gateway : un port, un adapter

Les conteneurs vont appeler une API. Plutôt que de balancer un HttpClient brut dans chaque conteneur, on passe par un gateway dédié, seul endroit qui connaît les URLs, les verbes et la forme des DTO. On le déclare comme un port abstrait, et son implémentation HTTP comme un adapter :

// users-api.ts - le port, ce dont dépendent les conteneurs
import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';

@Service({ factory: () => new HttpUsersApi(inject(HttpClient)) })
export abstract class UsersApi {
  abstract create(payload: UserPayload): Promise<User>;
  abstract update(id: string, payload: UserPayload): Promise<User>;
}

// http-users-api.ts - l'adapter HTTP, câblé par défaut
export class HttpUsersApi extends UsersApi {
  constructor(private readonly http: HttpClient) {
    super();
  }

  create(payload: UserPayload): Promise<User> {
    return firstValueFrom(this.http.post<User>('/api/users', payload));
  }

  update(id: string, payload: UserPayload): Promise<User> {
    return firstValueFrom(this.http.put<User>(`/api/users/${id}`, payload));
  }
}

Les conteneurs font inject(UsersApi) : ils dépendent du port abstrait, jamais de l'implémentation. Pour swapper (test, autre transport), on override sans toucher les conteneurs : { provide: UsersApi, useClass: InMemoryUsersApi }. Les décisions à verbaliser :

  • @Service() plutôt que @Injectable({ providedIn: 'root' }). Depuis Angular 22, c'est l'équivalent idiomatique : auto-provisionné en root, tree-shakable, mais plus court. L'overload @Service({ factory }) sait fabriquer une implémentation pour un type abstrait : le port est câblé à son adapter sans fichier de providers.
  • Pourquoi le scope root ? Parce que le gateway est stateless : pas de donnée mutable, juste des URLs et une réf à HttpClient (déjà root). Deux instances seraient interchangeables, une seule suffit. Règle : stateless + deps root, c'est un singleton root ; un état à isoler par écran, c'est scoped à la route.
  • Pourquoi Promise et pas Observable ? Create et update sont des commandes one-shot : un POST, une réponse, terminé. Consommées dans un submit async, await this.api.create(payload) se lit en une ligne. On aplatit l'Observable une fois, dans l'adapter.
  • Pourquoi la lecture n'utilise PAS ce gateway ? Le chargement initial en edit passe par httpResource, pas par le port. Règle : lecture = flux réactif (httpResource), écriture = commande impérative (Promise). Mettre un POST dans un resource serait un contresens : il se ré-exécuterait à chaque changement de dépendance, et tu re-POSTerais parce qu'un signal a bougé.

Le conteneur de création (signal vide)

@Component({
  selector: 'app-user-create',
  imports: [UserForm],
  template: `
    <h1>Nouvel utilisateur</h1>
    <app-user-form
      submitLabel="Créer"
      [pending]="pending()"
      (save)="onSave($event)" />
  `,
})
export default class UserCreatePage {
  private readonly api = inject(UsersApi);
  private readonly router = inject(Router);
  protected readonly pending = signal(false);

  protected async onSave(value: UserPayload): Promise<void> {
    this.pending.set(true);
    const created = await this.api.create(value);
    this.router.navigate(['/users', created.id]);
  }
}

Pas d'initialValue : le formulaire prend son défaut EMPTY_USER. Au submit, on create et on redirige vers la fiche créée.

Le conteneur d'édition (signal pré-rempli)

@Component({
  selector: 'app-user-edit',
  imports: [UserForm],
  template: `
    @if (user.value(); as u) {
      <h1>Modifier {{ u.firstName }}</h1>
      <app-user-form
        [initialValue]="u"
        submitLabel="Enregistrer"
        [pending]="pending()"
        (save)="onSave($event)" />
    } @else if (user.isLoading()) {
      <app-spinner />
    }
  `,
})
export default class UserEditPage {
  private readonly api = inject(UsersApi);
  private readonly router = inject(Router);
  readonly id = input.required<string>(); // depuis la route, withComponentInputBinding
  protected readonly pending = signal(false);

  // lecture = httpResource (re-fetch quand id() change), écriture = api.update
  protected readonly user = httpResource<User>(() => `/api/users/${this.id()}`);

  protected async onSave(value: UserPayload): Promise<void> {
    this.pending.set(true);
    await this.api.update(this.id(), value);
    this.router.navigate(['/users']);
  }
}

Regarde bien : le mot "edit" n'apparaît jamais dans UserForm. La décision create contre update est tranchée une seule fois, au niveau de l'appel API, dans le conteneur. Le formulaire émet un payload, point.


4. Un composant ou deux ? Le critère qui tranche

Pas de "ça dépend". Tu sépares toujours l'orchestration : deux conteneurs, point. La seule vraie question, c'est de savoir si tu partages la forme. Et il y a un test binaire pour répondre :

Est-ce que tu pourrais écrire la même fonction de schéma form(model, path => ...) pour les deux écrans, sans un seul if dedans ?

  • Oui -> la forme est commune -> 1 formulaire présentationnel + 2 conteneurs. Le cas par défaut.
  • Non, il te faudrait brancher sur le mode dans le schéma -> la forme a divergé -> 2 formulaires séparés. Tu arrêtes de partager ce qui n'est plus partagé.

Le schéma de validation est le juge de paix, parce qu'il encode à la fois les champs et les règles. Identique mot pour mot, tu partages. Dès qu'il a besoin de connaître le mode, tu scindes. C'est vérifiable mécaniquement, pas à l'intuition.

Et le fameux "un seul composant avec un mode" ? Oublie-le. Il ne survit pas au premier écran réel : create part d'un formulaire vide, edit charge une entité, la navigation post-submit diffère. Cette divergence d'orchestration te ramène à deux conteneurs de toute façon. Il y a deux régimes nets, pas trois.


5. Quatre pièges qui annulent tout le bénéfice

1. Le formulaire garde les données de l'entité précédente. De /users/1/edit à /users/2/edit, Angular réutilise la même instance. Un signal(initial), évalué une seule fois à la construction, garde le user 1. Bug invisible en dev (tu testes une fiche à la fois), bien présent en prod.

// bug : évalué une fois, jamais resynchronisé
protected readonly model = signal(this.initialValue());

// fix : re-dérivé dès que initialValue change, writable entre-temps
protected readonly model = linkedSignal(() => this.initialValue());

2. Le submit au mauvais étage. submit(f, action) gère le pending et route les erreurs serveur vers les champs, mais il lui faut f (dans le formulaire) alors que l'appel API vit dans le conteneur. La sortie : le formulaire garde submit et f, le conteneur fournit l'action en input.

// le formulaire possède submit + f ; l'action descend du conteneur
readonly action = input.required<(value: UserPayload) => Promise<void>>();

protected onSubmit(): void {
  submit(this.f, async () => {
    await this.action()(this.model()); // erreurs serveur mappées sur les champs ici
    return [];
  });
}

3. La validation create-only ne va pas dans le schéma partagé. L'unicité de l'email n'a de sens qu'en create (en edit, garder son propre email ne doit rien lever). Dans le schéma partagé, elle tourne aussi en edit, ou impose un if(mode) qui fait s'effondrer la thèse.

// bug : une règle mode-spécifique dans le schéma commun
form(model, (path) => {
  required(path.email);
  if (mode === 'create') uniqueEmail(path.email); // tourne aussi en edit
});

Le schéma partagé ne porte que les règles communes. L'unicité est une contrainte serveur : remonte-la comme une erreur de submit au POST (piège 2). Et si la forme diverge pour de bon, c'est ton signal de bascule vers deux formulaires.

4. Le dirty géré à la main, pour rien. Avec linkedSignal, pré-remplir est une dérivation, pas une frappe : f().dirty() reste false au chargement et ne passe à true que sur une vraie modif. Le guard est gratuit.

export const confirmLeave: CanDeactivateFn<{ dirty: () => boolean }> = (cmp) =>
  !cmp.dirty() || confirm('Modifications non enregistrées. Quitter ?');

Piège inverse : remplir via effect(() => model.set(initialValue())) au lieu de linkedSignal peut casser ça (un .set() ressemble à une mutation). Reste sur la dérivation.

Deux faux pièges pour la route : un if(mode) dans le formulaire (ce n'est pas un piège, c'est la thèse, remonte-le) et le <h1> de titre (il vit dans le conteneur, jamais dans la forme partagée).


Récap

  • Sépare toujours l'orchestration : deux conteneurs, create et edit.
  • Partage la forme tant que la fonction de schéma reste identique sans if(mode). Sinon, deux formulaires. Le schéma est le juge, pas l'intuition.
  • create = linkedSignal qui part de EMPTY_USER, edit = de la donnée chargée. La réinit entre entités est gratuite.
  • Lecture = httpResource, écriture = méthode de gateway. On n'injecte pas le gateway pour lire.
  • Binding [formField] (pas [control]), [formRoot] sur <form>, OnPush implicite en v22.

La phrase à coller en revue de code : le formulaire ignore s'il crée ou modifie. Il reçoit une valeur, il émet un payload. Tout le reste en découle.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.