x-data
Alpine
Alpine.js · Lifecycle · Memory Management · Hyvä
Alpine.js Lifecycle
init() und destroy() richtig nutzen

Der init()-Hook ist in jedem Alpine.js-Tutorial präsent – destroy() dagegen wird häufig übersehen. Genau das führt zu schleichenden Memory Leaks, verwaisten Event-Listenern und Fehlern, die erst nach mehreren Seitennavigationen auftreten. Dieser Artikel erklärt beide Hooks vollständig.

14 Min. Lesezeit init · destroy · x-data · Event Listener · Memory Leak Alpine.js 3.x · Hyvä Themes · Magento 2

1. Alpine.js Lifecycle im Überblick

Alpine.js kennt im Wesentlichen vier Lifecycle-Momente für eine Komponente: Initialisierung, Reaktivitäts-Updates, Re-Rendering und Destruction. Die praktisch relevanten Einstiegspunkte für Entwickler sind init() und destroy() in der x-data-Komponente, sowie die Direktive x-init und der globale Hook Alpine.onBeforeComponentInitialized. Dazu kommen die Magic Properties $nextTick und $watch, die zwar keine klassischen Lifecycle-Hooks sind, aber zeitlich im Lifecycle eingebettet werden. Das vollständige Verständnis der Reihenfolge dieser Momente ist Voraussetzung dafür, Komponenten korrekt zu initialisieren und sauber aufzuräumen.

Der häufigste Fehler in der Praxis: Entwickler registrieren Event-Listener in init() und vergessen, sie in destroy() wieder zu entfernen. Das ist kein theoretisches Problem. In Hyvä-Projekten, die Turbo oder AJAX-Navigation nutzen, werden Komponenten mount- und unmount-Zyklen tatsächlich mehrfach durchlaufen. Jede Navigation, die eine Komponente remountet, ohne die alte zu destroyen, stapelt einen weiteren Event-Listener auf denselben window- oder document-Event. Nach zehn Navigationen gibt es zehn Listener, die alle gleichzeitig reagieren. Das Ergebnis sind doppelte Aktionen, inkrementelle Performance-Verschlechterung und schwer reproduzierbare Bugs.

2. Der init()-Hook: Ausführungszeitpunkt und DOM-Zugriff

Die Methode init() in einem x-data-Objekt wird von Alpine.js automatisch aufgerufen, nachdem die Komponente initialisiert und das reaktive Datensystem eingerichtet wurde – aber bevor der erste Render-Durchlauf passiert. Das bedeutet: In init() kann man Daten setzen, API-Aufrufe starten und Listener registrieren. Man kann aber nicht davon ausgehen, dass x-text, x-show oder andere Direktiven bereits ausgewertet wurden und die entsprechenden DOM-Änderungen sichtbar sind. Der DOM-Baum der Komponente ist allerdings vorhanden und über this.$refs und this.$el zugänglich.

Ein wichtiger Unterschied zu Vue.js: Alpine.js hat kein mounted()-Äquivalent, das nach dem ersten Render läuft. Wer sichergehen will, dass eine Aktion nach dem ersten vollständigen Render passiert, muss this.$nextTick() innerhalb von init() verwenden. Das ist zum Beispiel notwendig, wenn man die Höhe eines gerenderten Elements messen will – das ist erst nach dem ersten Render korrekt. Ohne $nextTick gibt getBoundingClientRect() noch die Werte des vorherigen Zustands zurück.


// Correct use of init() and $nextTick for post-render DOM access
function tooltipComponent() {
  return {
    isOpen: false,
    tooltipHeight: 0,
    _scrollHandler: null,
    _keyHandler: null,

    init() {
      // Reactive data is ready, DOM is present but not yet rendered
      // Register listeners — store references for cleanup
      this._scrollHandler = () => this.isOpen = false;
      this._keyHandler    = (e) => { if (e.key === 'Escape') this.isOpen = false; };

      window.addEventListener('scroll', this._scrollHandler, { passive: true });
      document.addEventListener('keydown', this._keyHandler);

      // Measure rendered DOM — must wait for first render
      this.$nextTick(() => {
        const el = this.$refs.tooltipContent;
        if (el) this.tooltipHeight = el.getBoundingClientRect().height;
      });
    },

    destroy() {
      window.removeEventListener('scroll', this._scrollHandler);
      document.removeEventListener('keydown', this._keyHandler);
    }
  };
}

3. x-init als Direktive: Wann und warum

Neben der init()-Methode im x-data-Objekt gibt es die Direktive x-init, die direkt im HTML-Attribut eingesetzt wird. Der Unterschied ist nicht nur syntaktisch: x-init läuft nach der Initialisierung der Komponente und hat Zugriff auf alle reaktiven Daten. Es kann auf jedem Element innerhalb der Komponente stehen, nicht nur auf dem Root-Element. Das ermöglicht, Initialisierungslogik für einzelne Teil-Elemente zu lokalisieren, statt alles in der zentralen init()-Methode zu sammeln.

Die Direktive ist besonders nützlich für deklarative Initialisierungen, die ohne JavaScript-Funktion auskommen: x-init="fetch('/api/produkte').then(r => r.json()).then(d => produkte = d)". Das ist kompakt und selbst-dokumentierend. Allerdings hat x-init keinen Zugriff auf this.$refs, weil es zum Zeitpunkt der Auswertung noch kein DOM-Rendering gab. Für alles, was DOM-Zugriff braucht, ist init() im x-data-Objekt die richtige Wahl – oder x-init="$nextTick(() => ...)" als Kombination.

4. Der destroy()-Hook: Was er löst und wann er läuft

Der destroy()-Hook wurde in Alpine.js 3.x eingeführt und wird aufgerufen, wenn eine Komponente aus dem DOM entfernt wird. Das passiert in folgenden Situationen: Ein x-if wird false, das Element wird durch JavaScript aus dem DOM entfernt, oder die Seite wird in einer SPA-Navigation ersetzt. In Hyvä-Projekten mit Turbo-Navigation (Full Page Caching mit AJAX-Refresh) ist der destroy()-Hook besonders wichtig, weil Seitenübergänge die Alpine-Komponenten remounten.

Alpine.js ruft destroy() synchron auf, bevor das Element aus dem DOM entfernt wird. Das bedeutet, man hat noch Zugriff auf this.$el und alle Refs. Dieser Moment ist ideal, um Cleanup-Aktionen durchzuführen: Event-Listener entfernen, Timer stoppen, Observables unsubscrieben, Verbindungen schließen und Ressourcen freigeben. Wer keine destroy()-Methode definiert, muss sicherstellen, dass die Komponente keine globalen Ressourcen hält – was in der Praxis selten der Fall ist.


// Complete lifecycle management with multiple resource types
function videoPlayerComponent() {
  return {
    isPlaying: false,
    currentTime: 0,
    _resizeObserver: null,
    _intersectionObserver: null,
    _intervalId: null,
    _visibilityHandler: null,

    init() {
      const video = this.$refs.video;

      // ResizeObserver for responsive sizing
      this._resizeObserver = new ResizeObserver(entries => {
        for (const entry of entries) {
          this.updateLayout(entry.contentRect);
        }
      });
      this._resizeObserver.observe(this.$el);

      // Pause when scrolled out of view
      this._intersectionObserver = new IntersectionObserver(([entry]) => {
        if (!entry.isIntersecting && this.isPlaying) video.pause();
      }, { threshold: 0.2 });
      this._intersectionObserver.observe(this.$el);

      // Progress tracking
      this._intervalId = setInterval(() => {
        if (video) this.currentTime = video.currentTime;
      }, 500);

      // Pause on tab hide
      this._visibilityHandler = () => {
        if (document.hidden && this.isPlaying) video.pause();
      };
      document.addEventListener('visibilitychange', this._visibilityHandler);
    },

    destroy() {
      this._resizeObserver?.disconnect();
      this._intersectionObserver?.disconnect();
      clearInterval(this._intervalId);
      document.removeEventListener('visibilitychange', this._visibilityHandler);
    },

    updateLayout(rect) {
      // Adjust UI based on available width
    }
  };
}

5. Event-Listener-Cleanup: Das häufigste Memory-Leak-Muster

Das häufigste Memory-Leak-Pattern in Alpine.js-Projekten sieht so aus: In init() wird window.addEventListener('resize', () => this.handleResize()) registriert. Das Problem dabei ist doppelt. Erstens wird bei jedem init()-Aufruf eine neue anonyme Funktion erzeugt, die als Listener registriert wird. Zweitens hat removeEventListener keine Chance, diesen Listener wieder zu deregistrieren, weil es keine Referenz auf die anonyme Funktion gibt. Jedes Remount der Komponente stapelt einen weiteren Listener.

Die Lösung ist konsequentes Speichern von Listener-Referenzen. Jeder Listener, der in init() registriert wird, bekommt eine eigene Instanzvariable. Die Namenskonvention mit Unterstrich-Präfix (this._resizeHandler) signalisiert, dass es sich um interne, nicht reaktive Properties handelt. Alpine.js beobachtet Properties ohne Reaktivitätsbedarf reaktiv – wenn man viele Listener-Referenzen speichert, empfiehlt sich die Nutzung von Alpine.raw(this), um direkt auf das nicht-reaktive Objekt zuzugreifen und den Proxy-Overhead zu vermeiden.

6. Lifecycle in verschachtelten Komponenten

Wenn mehrere x-data-Komponenten ineinander verschachtelt sind, läuft der Lifecycle von außen nach innen bei der Initialisierung und von innen nach außen beim Destroy. Die äußere Komponente wird zuerst initialisiert, dann die innere. Beim Entfernen wird die innere Komponente zuerst destroyed, dann die äußere. Das ist wichtig zu wissen, wenn Komponenten aufeinander zugreifen: Im init() der inneren Komponente ist die äußere bereits fertig initialisiert und ihre Daten sind verfügbar über $store oder durch Scope-Vererbung.

Alpine.js-Komponenten erben den reaktiven Scope ihrer Eltern-Komponenten. Das bedeutet, eine Kind-Komponente kann auf die Eigenschaften der Eltern-Komponente zugreifen, ohne dass eine explizite Prop-Übergabe wie in Vue oder React nötig ist. Diese Scope-Vererbung ist mächtig, kann aber zu unerwarteten Abhängigkeiten führen. Wenn eine Kind-Komponente auf eine Eltern-Eigenschaft zugreift und die Eltern-Komponente destroys wird, während die Kind-Komponente noch aktiv ist, entstehen Fehler. In der Praxis verhindert das korrekte Lifecycle-Management diesen Fall.

7. Lifecycle mit x-if: Komponenten dynamisch mounten

x-if ist in Alpine.js anders als CSS-basiertes x-show: Es fügt das Element tatsächlich ins DOM ein bzw. entfernt es wieder. Das bedeutet, bei jedem Wechsel von false zu true wird die Komponente frisch gemountet und init() läuft erneut. Beim Wechsel von true zu false wird destroy() aufgerufen und das Element entfernt. Das ist eine saubere Lösung für Modals, Drawers und andere Elemente, die on-demand erscheinen und verschwinden.

Die Implikation für Performance: Jedes Mount einer komplexen Komponente kostet Zeit – DOM-Erstellung, Reaktivitäts-Setup, init()-Logik, API-Aufrufe. Bei Elementen, die sehr häufig ein- und ausgeblendet werden (wie Tooltips), ist x-show performanter, weil es nur CSS ändert und kein Re-Mount durchführt. Für Modals, die selten geöffnet werden und bei jedem Öffnen frische Daten laden sollen, ist x-if die bessere Wahl. Der Lifecycle-Hook-Zyklus von x-if ist damit nicht nur ein Detail, sondern eine Design-Entscheidung.


// x-if lifecycle: fresh mount each open, clean destroy each close
// Usage: <div x-data="modalComponent()"> <div x-if="isOpen" x-data="modalContent()">

function modalContent() {
  return {
    produkte: [],
    isLoading: true,
    _abortController: null,

    async init() {
      // Fresh data on every open — because x-if remounts
      this._abortController = new AbortController();
      try {
        const response = await fetch('/api/produkte', {
          signal: this._abortController.signal
        });
        this.produkte = await response.json();
      } catch (e) {
        if (e.name !== 'AbortError') console.error('Fetch failed:', e);
      } finally {
        this.isLoading = false;
      }

      // Focus trap: keep focus inside modal
      this._focusHandler = (e) => {
        const focusable = this.$el.querySelectorAll(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        const first = focusable[0];
        const last  = focusable[focusable.length - 1];
        if (e.key === 'Tab') {
          if (e.shiftKey && document.activeElement === first) {
            e.preventDefault(); last.focus();
          } else if (!e.shiftKey && document.activeElement === last) {
            e.preventDefault(); first.focus();
          }
        }
      };
      document.addEventListener('keydown', this._focusHandler);
    },

    destroy() {
      // Cancel in-flight request if modal closes before fetch completes
      this._abortController?.abort();
      document.removeEventListener('keydown', this._focusHandler);
    }
  };
}

8. Externe Ressourcen sauber laden und freigeben

Externe Ressourcen in Alpine.js-Komponenten umfassen neben Event-Listenern auch: setInterval- und setTimeout-IDs, ResizeObserver- und IntersectionObserver-Instanzen, WebSocket-Verbindungen, Fetch-Requests über AbortController, und Third-Party-SDK-Instanzen wie Karten-Bibliotheken oder Chart-Bibliotheken. Jede dieser Ressourcen muss in destroy() freigegeben werden, sonst entstehen Leaks. Der AbortController-Ansatz für Fetch-Requests ist dabei besonders elegant: Beim Destroy wird controller.abort() aufgerufen, was automatisch alle laufenden Fetch-Requests abbricht, die diesen Controller als signal verwenden.

ResizeObserver und IntersectionObserver sind häufig genutzte APIs für responsive Komponenten, die aber ohne disconnect() im Destroy-Hook zu Leaks führen. Das Pattern ist immer dasselbe: Instanz in einer Instanzvariablen speichern, im init()-Hook observe() aufrufen, im destroy()-Hook disconnect() aufrufen. Alpine.js-Komponenten, die diese Observers verwenden, ohne sie zu disconnecten, halten Referenzen auf DOM-Elemente, die längst nicht mehr existieren – was in V8 dazu führt, dass diese Elemente nicht vom Garbage Collector eingesammelt werden können.

9. Lifecycle-Hooks im Vergleich: Alpine vs. Vue vs. React

Lifecycle-Moment Alpine.js 3.x Vue 3 React (Hooks)
Vor erstem Render init() onBeforeMount() useState / useRef init
Nach erstem Render init() + $nextTick() onMounted() useEffect(fn, [])
Nach jedem Update $watch() onUpdated() useEffect(fn, [dep])
Beim Entfernen destroy() onUnmounted() useEffect cleanup fn
Fehlerbehandlung Kein Hook onErrorCaptured() Error Boundary

Alpine.js ist in seinem Lifecycle-Modell bewusst einfach gehalten. Es gibt keine Hooks für Vor/Nach-Update-Phasen einzelner Properties und keine Error-Boundary-Konzepte. Was Alpine.js hat, reicht für den Großteil der Anwendungsfälle in Hyvä-Projekten aus. Wer komplexe Lifecycle-Anforderungen hat, die über init/destroy hinausgehen, greift typischerweise auf $watch zurück, das reaktiv auf einzelne Datenwerte reagiert und beliebige Callbacks ausführen kann – inklusive Cleanup-Logik bei Wertänderungen.

Mironsoft

Alpine.js-Architektur, Hyvä Themes und Magento 2 Frontend-Entwicklung

Alpine.js-Komponenten mit sauberem Lifecycle?

Wir entwickeln Hyvä-Komponenten, die keine Memory Leaks hinterlassen – korrekte init/destroy-Implementierungen, sauberes Event-Listener-Management und robuste Ressourcen-Freigabe.

Leak-Analyse

Chrome DevTools Memory-Profiling für bestehende Alpine.js-Komponenten

Refactoring

Nachträgliche destroy()-Hooks und Listener-Cleanup in bestehenden Komponenten

Architektur

Komponentenstruktur für Hyvä-Projekte mit korrektem Lifecycle-Design von Anfang an

10. Zusammenfassung

Der Alpine.js Lifecycle mit init() und destroy() ist einfach in der API, aber folgenreich in der korrekten Anwendung. init() läuft nach dem Reaktivitäts-Setup, aber vor dem ersten Render – DOM-Messungen brauchen daher $nextTick. destroy() läuft synchron bevor das Element entfernt wird und muss alle globalen Ressourcen freigeben: Event-Listener, Observer, Timer, offene Verbindungen. Die Konsequenz fehlender Cleanup-Logik sind Memory Leaks und gestapelte Listener, die in Projekten mit dynamischer Navigation real auftreten.

Das konkrete Coding-Pattern: Jede Listener-Referenz in einer Instanzvariablen mit Unterstrich-Präfix speichern. In init() registrieren, in destroy() deregistrieren. Für Fetch-Requests einen AbortController nutzen und im Destroy abort() aufrufen. x-if nutzen wenn Komponenten frische Daten bei jedem Mount brauchen – der Mount/Unmount-Zyklus ist dann eine Feature, kein Bug. Dieses Pattern, konsequent angewendet, macht Alpine.js-Komponenten in Hyvä zu robusten, wartbaren Bausteinen.

Alpine.js Lifecycle — Das Wichtigste auf einen Blick

init()-Zeitpunkt

Nach Reaktivitäts-Setup, vor erstem Render. DOM vorhanden, aber Direktiven noch nicht ausgewertet. $nextTick() für Post-Render-Logik nutzen.

destroy()-Zeitpunkt

Synchron bevor das Element aus dem DOM entfernt wird. Noch Zugriff auf $el und $refs. Alle globalen Ressourcen hier freigeben.

Event-Listener-Cleanup

Listener-Referenz in this._handler speichern. addEventListener in init(), removeEventListener in destroy(). Anonyme Funktionen nicht verwendbar für Cleanup.

x-if vs. x-show

x-if: echtes Mount/Unmount, frische Daten, Lifecycle-Hooks laufen. x-show: nur CSS, kein Re-Mount, kein Lifecycle. Nach Use-Case wählen.

11. FAQ: Alpine.js Lifecycle init() und destroy()

1Wann genau läuft init() in Alpine.js?
Nach Reaktivitäts-Setup, vor erstem Render. DOM vorhanden, Direktiven noch nicht ausgewertet. Für Post-Render-Aktionen $nextTick() innerhalb von init() verwenden.
2init() vs. x-init – was ist der Unterschied?
init() ist Methode im x-data-Objekt mit vollem this-Zugriff und $refs. x-init ist HTML-Direktive für einfache Initialisierungsausdrücke ohne DOM-Zugriff.
3Wann wird destroy() aufgerufen?
Bei x-if=false, DOM-Entfernen, SPA-Navigation. Synchron bevor das Element entfernt wird – noch Zugriff auf $el und $refs.
4Warum anonyme Funktionen nicht für removeEventListener?
Jede anonyme Funktion ist eine neue Instanz – removeEventListener kann sie nicht finden. Referenz in this._handler speichern und beide Male nutzen.
5Elementhöhe in init() messen?
this.$nextTick(() => { h = this.$refs.el.getBoundingClientRect().height }). Ohne $nextTick gibt getBoundingClientRect noch vorherige Werte zurück.
6Lifecycle in verschachtelten Komponenten?
Init: außen nach innen. Destroy: innen nach außen. Kind-Komponenten haben Zugriff auf Eltern-Daten während init() läuft.
7x-if und erneuter init()-Aufruf?
x-if entfernt und fügt Element echt ein. Kein CSS-Toggle. Jeder Wechsel zu true = frisches Mount mit neuem init(). x-show wenn kein Re-Mount gewünscht.
8Fetch-Request im destroy()-Hook abbrechen?
AbortController: init erstellt controller, fetch bekommt signal, destroy ruft abort() auf. Request wird mit AbortError abgebrochen.
9ResizeObserver in destroy() disconnecten?
Ja, obligatorisch. Ohne disconnect() hält Observer Referenz auf DOM-Element – verhindert Garbage Collection und führt zu Memory Leaks.
10Hook nach jedem Render in Alpine.js?
Kein onUpdated()-Äquivalent. $watch() reagiert auf einzelne Properties. Alpine.js ist bewusst auf einfache Lifecycle-APIs ausgelegt.