Luca Mele
Luca Mele

Architecture

Construire un formulaire multi-étapes avec des Web Components, sans framework

Construire un formulaire multi-étapes avec des Web Components, sans framework
Retour aux articles
·10 min de lecture
Écouter le podcast généré par IA
Tous les podcasts →

J'ai construit un formulaire multi-étapes de calcul de salaire pour un client. Il a la validation native, un wizard par étapes, l'i18n en 4 langues, du style Tailwind CSS et un backend Express. La liste complète des dépendances est de 5 packages. Pas de React. Pas de form library. Pas de state management library.

J'ai construit cela comme un proof of concept pour un client — pour démontrer qu'on n'a pas besoin d'un framework lourd pour construire une vraie application de formulaire interactive. Il traite les entrées utilisateur, les valide et les envoie à un serveur. Et ça prouve quelque chose sur lequel je reviens constamment dans mon travail : on peut construire des applications sérieuses en utilisant les capacités natives de la plateforme — si on sait ce que la plateforme offre.

J'ai déjà écrit sur la Rule of Least Power et YAGNI. Ce projet est là où ces principes rencontrent du vrai code. Laissez-moi vous montrer comment ça fonctionne, en me concentrant sur les deux patterns qui le rendent intéressant : la communication événementielle à travers les frontières du Shadow DOM, et la validation native des formulaires HTML dans un wizard multi-étapes.

Le setup : un fichier HTML, un Custom Element

L'application entière démarre depuis un seul fichier HTML avec un seul custom element. Pas de bootstrap de framework, pas de div racine, pas d'étape d'hydratation. Le navigateur parse le HTML, trouve le custom element, et le composant prend le relais.

Voilà à quoi ressemble « utiliser la plateforme ». Les Custom Elements sont un modèle de composants natif du navigateur. Ils ont des callbacks de cycle de vie, l'observation d'attributs et l'encapsulation Shadow DOM — les mêmes choses que les composants React vous donnent, mais sans DOM virtuel, reconciler ou runtime de 40 Ko.

<!-- The entire HTML file. That's it. -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Minimum Wage App</title>
  </head>
  <body>
    <minimum-wage-app></minimum-wage-app>
    <script type="module" src="./app.ts"></script>
  </body>
</html>

<!-- No framework bootstrap. No root div.
     One custom element. One script tag. -->

Transmission d'événements : CustomEvent + bubbles + composed

C'est le pattern architectural central. React a aussi de bonnes solutions pour la communication découplée — les context providers, les render props, le pattern dont j'ai écrit où le child est le provider et le parent consomme. Mais tout cela sont des abstractions spécifiques à React. Avec les Web Components, vous obtenez le découplage gratuitement via le système d'événements natif du DOM.

Le child dispatch un CustomEvent avec bubbles: true et composed: true. bubbles le fait remonter dans l'arbre DOM. composed le fait traverser les frontières du Shadow DOM. Le parent l'attrape avec un event listener standard. Le child ne sait pas et ne se soucie pas de qui écoute. Et cela fonctionne dans n'importe quel framework — ou sans framework du tout.

C'est le même pattern que le navigateur utilise pour les événements click, input et submit. Vous n'inventez pas un mécanisme de communication — vous utilisez celui qui est dans chaque navigateur depuis des décennies. C'est un couplage plus lâche que les props, plus découvrable qu'un state store global, et zéro octet de code additionnel.

La ligne clé est Object.fromEntries(formData.entries()). L'API FormData du navigateur collecte automatiquement toutes les valeurs du formulaire — pas de useState par champ, pas de controlled components, pas de two-way binding. Une ligne transforme un formulaire natif en objet simple.

// Form component: dispatches a CustomEvent when valid
handleSubmit(e: Event) {
  e.preventDefault();
  const form = e.target as HTMLFormElement;
  const formData = new FormData(form);

  if (form.checkValidity()) {
    this.dispatchEvent(
      new CustomEvent('form-submit', {
        detail: { formData: Object.fromEntries(formData.entries()) },
        bubbles: true,    // rises through the DOM
        composed: true,   // crosses Shadow DOM boundaries
      })
    );
  } else {
    form.reportValidity(); // native browser validation UI
  }
}

// Parent page: catches it with Lit's @event syntax
// (shorthand for addEventListener — works the same way)
html`
  <div @form-submit="${this.handleFormSubmit}">
    <wage-form></wage-form>
  </div>
`

Validation native : laissez le navigateur faire le travail

Chaque form library que vous avez utilisée fait fondamentalement ce que le navigateur fait déjà : vérifier si un champ est requis, s'il correspond à un pattern, si une date est dans la plage. L'API Constraint Validation de HTML gère tout cela nativement.

required, min, max, pattern, type="date", type="email" — ce ne sont pas des suggestions. C'est un framework de validation complet intégré au navigateur. Le navigateur affiche des messages d'erreur accessibles, empêche la soumission, annonce les erreurs aux lecteurs d'écran. Tout gratuitement.

Le pattern dirty-state — ajouter une classe CSS au premier input — résout le problème UX classique d'afficher des erreurs avant que l'utilisateur ait tapé quoi que ce soit. Un event handler, une règle CSS, zéro state management.

<!-- Native HTML validation — zero JavaScript needed -->
<input type="date" name="start_date"
  min="2024-06-10" max="2024-08-05" required />

<input type="text" name="location"
  pattern="^\d{4,5}$|^[A-Za-z]{2,}$"
  required placeholder="Zip or city" />

<!-- The browser validates, shows native error tooltips,
     blocks submission. You write zero validation logic. -->

<!-- CSS handles dirty-state error display: -->
<style>
  .dirty:invalid + span { display: block; }
  span { display: none; }
</style>

<!-- Add 'dirty' class on first input to avoid
     flash-of-red on page load.
     Note: @input is Lit's shorthand for addEventListener('input', ...).
     In vanilla JS you'd write: el.addEventListener('input', handler) -->
<input @input="${(e) => e.target.classList.add('dirty')}" />
<span class="text-red-500">Please enter a valid location</span>

Validation multi-étapes à travers le Shadow DOM

C'est là que ça devient intéressant. Dans un wizard multi-étapes, vous devez valider l'étape courante avant d'avancer. Mais les inputs vivent dans du contenu slotté — ils sont dans le DOM du parent, projetés dans le Shadow DOM du child via les slots.

La solution utilise slot.assignedElements() pour accéder au contenu slotté et interroger les vrais éléments input. Puis elle appelle le checkValidity() natif du navigateur sur chacun. Pas de form library nécessaire. Le navigateur sait déjà si chaque input est valide.

Il y a une astuce ingénieuse pour l'affichage des erreurs : quand la validation échoue, le composant montre temporairement toutes les étapes (pour que le navigateur puisse trouver le champ invalide), appelle reportValidity() (qui montre le tooltip d'erreur natif), puis cache les étapes à nouveau. L'UI propre du navigateur pointe vers le champ invalide exact — meilleure UX que n'importe quel message d'erreur personnalisé.

Le résultat de validation remonte comme un autre CustomEvent (step-validated), que le formulaire parent attrape pour activer/désactiver le bouton submit. Encore une fois — des événements DOM, pas des callbacks ou du state.

// Multi-step wizard: validate before advancing
private validateStep() {
  // Query the slot for the current step
  const stepSlot = this.shadowRoot
    ?.querySelector(`slot[name="step${this.currentStep}"]`);
  const assignedElements = stepSlot.assignedElements({ flatten: true });

  let isValid = true;
  assignedElements.forEach((element) => {
    element.querySelectorAll('input').forEach((input) => {
      if (!input.checkValidity()) isValid = false;
    });
  });

  // Bubble validation result up as a CustomEvent
  this.dispatchEvent(new CustomEvent('step-validated', {
    detail: { step: this.currentStep, isValid },
    bubbles: true, composed: true,
  }));

  return isValid;
}

private nextStep() {
  if (this.validateStep()) {
    this.currentStep++;
  } else {
    // Temporarily show all steps so the browser can
    // point to the invalid field with its native tooltip
    this.showAllSteps = true;
    setTimeout(() => {
      this.formElement.reportValidity();
      this.showAllSteps = false;
    }, 1);
  }
}

Slots plutôt que props : composition sans couplage

En React, vous construiriez un formulaire multi-étapes en passant le contenu des étapes comme children ou render props. Le parent et le child sont couplés à travers l'API du composant.

Avec les Web Components, les slots fournissent une projection de contenu native. Le parent définit ce qui va dans chaque étape. Le child contrôle quel slot est visible. Ni l'un ni l'autre n'a besoin de connaître l'implémentation de l'autre. Vous pouvez remplacer entièrement le composant step wizard sans toucher aux champs du formulaire.

C'est l'équivalent Web Components de la composition plutôt que l'héritage — mais c'est intégré au navigateur. Pas de prop children, pas de render callback, pas de React.cloneElement. Juste des slots nommés.

// Parent defines content. Child controls visibility.
// No props drilling. No render callbacks. Just slots.

// wage-form.ts — parent composes steps as slotted content
html`
  <wage-form-step .totalStep=${3} .formElement=${this.formElement}>
    <div slot="step1">
      <input type="date" name="start_date" required />
      <input type="text" name="location" pattern="..." required />
    </div>
    <div slot="step2">
      <input type="date" name="birthday_date" required />
    </div>
    <div slot="step3">
      <input type="radio" name="function" value="Bricklayer" required />
      <input type="radio" name="function" value="Plumber" required />
    </div>
  </wage-form-step>
`

// wage-form-step.ts — child renders slots based on step
html`
  ${this.getStepSlots().map((step) => html`
    <slot name="step${step}"
      ?hidden="${this.currentStep !== step}">
    </slot>
  `)}
`

5 dépendances. C'est toute l'app.

Le projet entier tourne sur Lit (3 Ko de runtime), i18next pour les traductions, Tailwind pour le style, Express pour le backend et Vite pour le build. C'est tout.

Lit est intentionnellement la couche la plus fine possible au-dessus des Web Components — il vous donne des propriétés réactives, un re-rendering efficace et des template literals. Il ne réinvente pas le DOM. Il n'ajoute pas de DOM virtuel. Il ne livre pas de reconciler. Vos composants sont de vrais Custom Elements qui fonctionnent dans n'importe quel framework ou sans framework.

Comparez avec un projet de formulaire React typique. Avant d'écrire une ligne de logique métier, vous avez installé react, react-dom, une form library (react-hook-form ou formik), une library de schéma de validation (yup ou zod), un router, une solution de styling, possiblement une library de state management. C'est 10-15 dépendances, chacune avec son propre cycle de mise à jour, ses breaking changes et sa surface de sécurité.

{
  "dependencies": {
    "express": "^4.21.0",
    "i18next": "^23.15.2",
    "i18next-browser-languagedetector": "^8.0.0",
    "lit": "^3.2.0",
    "tailwindcss": "^3.4.13"
  }
}

// 5 dependencies. That's the entire runtime.
// No React. No Redux. No form library.
// No state management. No virtual DOM.
//
// Compare to a typical React form project:
//   react, react-dom, react-hook-form OR formik,
//   yup OR zod, react-router, @emotion OR styled-components,
//   some state lib, some i18n lib...
//   → 10-15 dependencies before you write a line of business logic.

Quand cette approche convient (et quand non)

Ça fonctionne bien pour : les petites et moyennes apps avec des flux riches en formulaires, les wizards multi-étapes, les outils internes, les micro-frontends qui doivent fonctionner à travers les frameworks, et tout projet où vous voulez minimiser l'overhead runtime.

Ça ne remplace pas React pour : les grandes SPAs avec du routing client complexe, les apps avec du state temps réel lourd (édition collaborative, chat), ou les équipes déjà productives en React sans raison de changer.

Le propos n'est pas « n'utilisez jamais React. » C'est que pour une catégorie étonnamment large d'applications — formulaires, dashboards, pages riches en contenu, widgets embarqués — vous n'avez pas besoin d'un runtime de framework de 40 Ko. Le navigateur vous donne des composants (Custom Elements), l'encapsulation du style (Shadow DOM), la projection de contenu (slots), les événements (CustomEvent), la validation (Constraint Validation API) et la collecte de données (FormData). C'est un framework d'application complet, et il est livré avec chaque navigateur.

Essayez vous-même

Le code source complet est sur GitHub : github.com/LucaMele/modern-small-form. Clonez-le, lancez npm install && npm run dev, et voyez comment un vrai formulaire multi-étapes fonctionne avec 5 dépendances et zéro overhead de framework.

Si vous avez construit des formulaires avec React + react-hook-form + zod, passez 30 minutes à lire ce codebase. Vous serez peut-être surpris de voir combien de ce que vous installez via npm, le navigateur le fournit déjà.