Luca Mele
Luca Mele

Architecture

Ein Multi-Step-Formular mit Web Components bauen — ohne Framework

Ein Multi-Step-Formular mit Web Components bauen — ohne Framework
Zurück zu allen Beiträgen
·10 Min. Lesezeit
KI-generierten Podcast anhören
Alle Podcasts →

Ich habe ein Multi-Step-Lohnberechnungsformular für einen Kunden gebaut. Es hat native Validierung, einen Schritt-Wizard, i18n in 4 Sprachen, Tailwind CSS und ein Express-Backend. Die gesamte Abhängigkeitsliste umfasst 5 Pakete. Kein React. Keine Form-Library. Keine State-Management-Library.

Ich habe das als Proof of Concept für einen Kunden gebaut — um zu zeigen, dass man kein schweres Framework braucht, um eine echte, interaktive Formular-Anwendung zu bauen. Es verarbeitet Benutzereingaben, validiert sie und sendet sie an einen Server. Und es beweist etwas, zu dem ich in meiner Arbeit immer wieder zurückkehre: Man kann ernsthafte Anwendungen mit den nativen Fähigkeiten der Plattform bauen — wenn man weiss, was die Plattform bietet.

Ich habe bereits über die Rule of Least Power und YAGNI geschrieben. Dieses Projekt ist dort, wo diese Prinzipien auf echten Code treffen. Lass mich durchgehen, wie es funktioniert, mit Fokus auf die zwei Patterns, die es interessant machen: eventgesteuerte Kommunikation über Shadow-DOM-Grenzen hinweg und native HTML-Formularvalidierung in einem Multi-Step-Wizard.

Das Setup: Eine HTML-Datei, ein Custom Element

Die gesamte Anwendung startet von einer einzigen HTML-Datei mit einem einzigen Custom Element. Kein Framework-Bootstrap, kein Root-Div, kein Hydration-Schritt. Der Browser parst das HTML, findet das Custom Element, und die Komponente übernimmt.

So sieht «die Plattform nutzen» aus. Custom Elements sind ein browser-natives Komponentenmodell. Sie haben Lifecycle-Callbacks, Attribut-Observation und Shadow-DOM-Kapselung — dieselben Dinge, die React-Komponenten bieten, aber ohne Virtual DOM, Reconciler oder 40KB Runtime.

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

Event-Übertragung: CustomEvent + bubbles + composed

Das ist das zentrale Architektur-Pattern. React hat auch gute Lösungen für entkoppelte Kommunikation — Context Provider, Render Props, das Pattern das ich beschrieben habe, wo das Child der Provider ist und der Parent konsumiert. Aber all das sind React-spezifische Abstraktionen. Mit Web Components bekommt man Entkopplung gratis über das native Event-System des DOM.

Das Child dispatcht ein CustomEvent mit bubbles: true und composed: true. bubbles lässt es durch den DOM-Baum aufsteigen. composed lässt es Shadow-DOM-Grenzen überqueren. Der Parent fängt es mit einem Standard-Event-Listener. Das Child weiss nicht und kümmert sich nicht darum, wer zuhört. Und das funktioniert in jedem Framework — oder ganz ohne.

Das ist dasselbe Pattern, das der Browser für click-, input- und submit-Events nutzt. Man erfindet keinen Kommunikationsmechanismus — man nutzt den, der seit Jahrzehnten in jedem Browser ist. Es ist losere Kopplung als Props, besser auffindbar als ein globaler State-Store und null Bytes zusätzlicher Code.

Die Schlüsselzeile ist Object.fromEntries(formData.entries()). Die FormData-API des Browsers sammelt alle Formularwerte automatisch — kein useState pro Feld, keine Controlled Components, kein Two-Way-Binding. Eine Zeile verwandelt ein natives Formular in ein Plain Object.

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

Native Validierung: Lass den Browser die Arbeit machen

Jede Form-Library, die du je benutzt hast, macht grundlegend das, was der Browser bereits tut: prüfen, ob ein Feld erforderlich ist, ob es einem Pattern entspricht, ob ein Datum im Bereich liegt. Die Constraint-Validation-API von HTML handhabt all das nativ.

required, min, max, pattern, type="date", type="email" — das sind keine Vorschläge. Es ist ein vollständiges Validierungs-Framework, das in den Browser eingebaut ist. Der Browser zeigt barrierefreie Fehlermeldungen, verhindert das Absenden, gibt Fehler an Screenreader weiter. Alles kostenlos.

Das Dirty-State-Pattern — eine CSS-Klasse beim ersten Input hinzufügen — löst das klassische UX-Problem, Fehler anzuzeigen, bevor der Benutzer etwas getippt hat. Ein Event-Handler, eine CSS-Regel, null 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>

Multi-Step-Validierung über Shadow-DOM-Grenzen

Hier wird es interessant. In einem Multi-Step-Wizard muss man den aktuellen Schritt validieren, bevor man weitergeht. Aber die Inputs leben in Slotted Content — sie sind im DOM des Parents und werden über Slots in das Shadow DOM des Childs projiziert.

Die Lösung nutzt slot.assignedElements(), um in den Slotted Content zu greifen und die tatsächlichen Input-Elemente abzufragen. Dann wird die native checkValidity() des Browsers auf jedem aufgerufen. Keine Form-Library nötig. Der Browser weiss bereits, ob jedes Input gültig ist.

Es gibt einen cleveren Trick für die Fehleranzeige: Wenn die Validierung fehlschlägt, zeigt die Komponente vorübergehend alle Schritte an (damit der Browser das ungültige Feld finden kann), ruft reportValidity() auf (was den nativen Fehler-Tooltip zeigt), und versteckt die Schritte dann wieder. Die eigene UI des Browsers zeigt auf das exakte ungültige Feld — bessere UX als jede eigene Fehlermeldung.

Das Validierungsergebnis steigt als weiteres CustomEvent (step-validated) auf, das das Parent-Formular auffängt, um den Submit-Button zu aktivieren/deaktivieren. Wieder — DOM-Events, keine Callbacks oder 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 statt Props: Komposition ohne Kopplung

In React würde man ein Multi-Step-Formular bauen, indem man Step-Inhalte als Children oder Render Props übergibt. Parent und Child sind über die Komponenten-API gekoppelt.

Mit Web Components bieten Slots native Content-Projection. Der Parent definiert, was in jeden Schritt kommt. Das Child kontrolliert, welcher Slot sichtbar ist. Keiner muss die Implementierung des anderen kennen. Man kann die Step-Wizard-Komponente komplett austauschen, ohne die Formularfelder zu berühren.

Das ist das Web-Components-Äquivalent von Komposition über Vererbung — aber es ist in den Browser eingebaut. Kein Children-Prop, kein Render-Callback, kein React.cloneElement. Nur benannte Slots.

// 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 Abhängigkeiten. Das ist die ganze App.

Das gesamte Projekt läuft auf Lit (3KB Runtime), i18next für Übersetzungen, Tailwind für Styling, Express für das Backend und Vite zum Bauen. Das war's.

Lit ist absichtlich die dünnste mögliche Schicht über Web Components — es gibt dir reaktive Properties, effizientes Re-Rendering und Template Literals. Es erfindet das DOM nicht neu. Es fügt kein Virtual DOM hinzu. Es liefert keinen Reconciler. Deine Komponenten sind echte Custom Elements, die in jedem Framework oder ohne Framework funktionieren.

Vergleiche das mit einem typischen React-Formularprojekt. Bevor du eine Zeile Business-Logik schreibst, hast du react, react-dom, eine Form-Library (react-hook-form oder formik), eine Validierungsschema-Library (yup oder zod), einen Router, eine Styling-Lösung, möglicherweise eine State-Management-Library installiert. Das sind 10-15 Abhängigkeiten, jede mit eigenem Update-Zyklus, Breaking Changes und Sicherheitsoberfläche.

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

Wann dieser Ansatz passt (und wann nicht)

Das funktioniert gut für: kleine bis mittlere Apps mit formularintensiven Flows, Multi-Step-Wizards, interne Tools, Micro-Frontends die frameworkübergreifend funktionieren müssen, und jedes Projekt, bei dem man Runtime-Overhead minimieren will.

Das ersetzt React nicht für: grosse SPAs mit komplexem clientseitigem Routing, Apps mit starkem Echtzeit-State (kollaboratives Editieren, Chat), oder Teams die bereits produktiv in React sind und keinen Grund zum Wechseln haben.

Der Punkt ist nicht «benutze nie React.» Es ist, dass für eine überraschend grosse Kategorie von Anwendungen — Formulare, Dashboards, Content-lastige Seiten, eingebettete Widgets — du keine 40KB Framework-Runtime brauchst. Der Browser gibt dir Komponenten (Custom Elements), Styling-Kapselung (Shadow DOM), Content-Projection (Slots), Events (CustomEvent), Validierung (Constraint Validation API) und Datenerhebung (FormData). Das ist ein vollständiges Anwendungs-Framework, und es wird mit jedem Browser ausgeliefert.

Probier es selbst aus

Der vollständige Quellcode ist auf GitHub: github.com/LucaMele/modern-small-form. Klone es, führe npm install && npm run dev aus, und sieh wie ein echtes Multi-Step-Formular mit 5 Abhängigkeiten und null Framework-Overhead funktioniert.

Wenn du bisher Formulare mit React + react-hook-form + zod gebaut hast, verbringe 30 Minuten damit, diese Codebase durchzulesen. Du wirst vielleicht überrascht sein, wie viel von dem, was du per npm installierst, der Browser bereits bietet.