Luca Mele
Luca Mele

Architecture

Costruire un form multi-step con Web Components, senza framework

Costruire un form multi-step con Web Components, senza framework
Torna a tutti gli articoli
·10 min di lettura
Ascolta il podcast generato dall'IA
Tutti i podcast →

Ho costruito un form multi-step per il calcolo salariale per un cliente. Ha validazione nativa, un wizard a step, i18n in 4 lingue, styling Tailwind CSS e un backend Express. L'intera lista di dipendenze è di 5 pacchetti. Niente React. Niente form library. Niente state management library.

L'ho costruito come proof of concept per un cliente — per dimostrare che non serve un framework pesante per costruire una vera applicazione form interattiva. Gestisce input utente reali, li valida e li invia a un server. E dimostra qualcosa a cui torno costantemente nel mio lavoro: si possono costruire applicazioni serie usando le capacità native della piattaforma — se si sa cosa la piattaforma offre.

Ho già scritto sulla Rule of Least Power e YAGNI. Questo progetto è dove quei principi incontrano il codice reale. Lasciatemi mostrare come funziona, concentrandomi sui due pattern che lo rendono interessante: la comunicazione event-driven attraverso i confini del Shadow DOM, e la validazione nativa dei form HTML in un wizard multi-step.

Il setup: un file HTML, un Custom Element

L'intera applicazione parte da un singolo file HTML con un singolo custom element. Nessun bootstrap di framework, nessun div root, nessuno step di idratazione. Il browser parsa l'HTML, trova il custom element, e il componente prende il controllo.

Ecco come appare «usare la piattaforma». I Custom Elements sono un modello di componenti nativo del browser. Hanno callback del ciclo di vita, osservazione degli attributi e incapsulamento Shadow DOM — le stesse cose che i componenti React ti danno, ma senza DOM virtuale, reconciler o runtime da 40KB.

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

Trasmissione eventi: CustomEvent + bubbles + composed

Questo è il pattern architetturale centrale. React ha anche buone soluzioni per la comunicazione disaccoppiata — context provider, render props, il pattern di cui ho scritto dove il child è il provider e il parent consuma. Ma sono tutte astrazioni specifiche di React. Con i Web Components, ottieni il disaccoppiamento gratis tramite il sistema di eventi nativo del DOM.

Il child dispatcha un CustomEvent con bubbles: true e composed: true. bubbles lo fa salire attraverso l'albero DOM. composed lo fa attraversare i confini del Shadow DOM. Il parent lo cattura con un event listener standard. Il child non sa e non si preoccupa di chi sta ascoltando. E questo funziona in qualsiasi framework — o senza framework.

È lo stesso pattern che il browser usa per gli eventi click, input e submit. Non stai inventando un meccanismo di comunicazione — stai usando quello che è in ogni browser da decenni. È un accoppiamento più lasco dei props, più scopribile di un global state store, e zero byte di codice aggiuntivo.

La riga chiave è Object.fromEntries(formData.entries()). L'API FormData del browser raccoglie automaticamente tutti i valori del form — nessun useState per campo, nessun controlled component, nessun two-way binding. Una riga trasforma un form nativo in un oggetto semplice.

// 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>
`

Validazione nativa: lascia che il browser faccia il lavoro

Ogni form library che hai mai usato fa fondamentalmente quello che il browser già fa: controllare se un campo è obbligatorio, se corrisponde a un pattern, se una data è nel range. L'API Constraint Validation di HTML gestisce tutto questo nativamente.

required, min, max, pattern, type="date", type="email" — questi non sono suggerimenti. Sono un framework di validazione completo integrato nel browser. Il browser mostra messaggi di errore accessibili, impedisce l'invio, annuncia gli errori agli screen reader. Tutto gratis.

Il pattern dirty-state — aggiungere una classe CSS al primo input — risolve il classico problema UX di mostrare errori prima che l'utente abbia digitato qualcosa. Un event handler, una regola CSS, zero 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>

Validazione multi-step attraverso il Shadow DOM

Qui diventa interessante. In un wizard multi-step, devi validare lo step corrente prima di avanzare. Ma gli input vivono in contenuto slottato — sono nel DOM del parent, proiettati nel Shadow DOM del child tramite gli slot.

La soluzione usa slot.assignedElements() per raggiungere il contenuto slottato e interrogare i veri elementi input. Poi chiama il checkValidity() nativo del browser su ciascuno. Nessuna form library necessaria. Il browser sa già se ogni input è valido.

C'è un trucco ingegnoso per la visualizzazione degli errori: quando la validazione fallisce, il componente mostra temporaneamente tutti gli step (così il browser può trovare il campo invalido), chiama reportValidity() (che mostra il tooltip di errore nativo), poi nasconde di nuovo gli step. L'UI propria del browser punta al campo invalido esatto — UX migliore di qualsiasi messaggio di errore personalizzato.

Il risultato della validazione sale come un altro CustomEvent (step-validated), che il form parent cattura per abilitare/disabilitare il pulsante submit. Di nuovo — eventi DOM, non callback o 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);
  }
}

Slot invece di props: composizione senza accoppiamento

In React, costruiresti un form multi-step passando il contenuto degli step come children o render props. Parent e child sono accoppiati attraverso l'API del componente.

Con i Web Components, gli slot forniscono proiezione di contenuto nativa. Il parent definisce cosa va in ogni step. Il child controlla quale slot è visibile. Nessuno dei due deve conoscere l'implementazione dell'altro. Puoi sostituire completamente il componente step wizard senza toccare i campi del form.

Questo è l'equivalente Web Components della composizione rispetto all'ereditarietà — ma è integrato nel browser. Nessun prop children, nessun render callback, nessun React.cloneElement. Solo slot nominati.

// 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 dipendenze. Questa è tutta l'app.

L'intero progetto gira su Lit (3KB di runtime), i18next per le traduzioni, Tailwind per lo styling, Express per il backend e Vite per il build. Tutto qui.

Lit è intenzionalmente il layer più sottile possibile sopra i Web Components — ti dà proprietà reattive, re-rendering efficiente e template literal. Non reinventa il DOM. Non aggiunge un DOM virtuale. Non include un reconciler. I tuoi componenti sono veri Custom Elements che funzionano in qualsiasi framework o senza framework.

Confronta con un tipico progetto form React. Prima di scrivere una riga di logica di business, hai installato react, react-dom, una form library (react-hook-form o formik), una library di schema di validazione (yup o zod), un router, una soluzione di styling, possibilmente una library di state management. Sono 10-15 dipendenze, ciascuna con il proprio ciclo di aggiornamento, breaking change e superficie di sicurezza.

{
  "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.

Quando questo approccio è adatto (e quando no)

Funziona bene per: app piccole e medie con flussi ricchi di form, wizard multi-step, tool interni, micro-frontend che devono funzionare attraverso i framework, e qualsiasi progetto dove vuoi minimizzare l'overhead runtime.

Non sostituisce React per: grandi SPA con routing client complesso, app con state real-time pesante (editing collaborativo, chat), o team già produttivi in React senza motivo di cambiare.

Il punto non è «non usare mai React.» È che per una categoria sorprendentemente ampia di applicazioni — form, dashboard, pagine ricche di contenuto, widget embedded — non hai bisogno di un runtime framework da 40KB. Il browser ti dà componenti (Custom Elements), incapsulamento dello stile (Shadow DOM), proiezione di contenuto (slot), eventi (CustomEvent), validazione (Constraint Validation API) e raccolta dati (FormData). È un framework applicativo completo, e viene fornito con ogni browser.

Provalo tu stesso

Il codice sorgente completo è su GitHub: github.com/LucaMele/modern-small-form. Clonalo, lancia npm install && npm run dev, e guarda come un vero form multi-step funziona con 5 dipendenze e zero overhead di framework.

Se hai costruito form con React + react-hook-form + zod, passa 30 minuti a leggere questo codebase. Potresti essere sorpreso di quanto di ciò che installi via npm, il browser lo fornisce già.