Alpine.js Lifecycle: Using init() and destroy() Correctly
AI generated
x-data
Alpine
Alpine.js · Lifecycle · Memory Management · Hyva
Alpine.js Lifecycle
Using init() and destroy() Correctly

The init() hook shows up in every Alpine.js tutorial, but destroy() is frequently overlooked. That is exactly what causes slow-building memory leaks, orphaned event listeners and bugs that only surface after several page navigations. This article explains both hooks in full.

14 min read init · destroy · x-data · event listeners · memory leaks Alpine.js 3.x · Hyva Themes · Magento 2

1. The Alpine.js lifecycle at a glance

Alpine.js essentially has four lifecycle moments for a component: initialization, reactivity updates, re-rendering and destruction. The entry points that matter in practice are init() and destroy() inside the x-data component, plus the x-init directive and the global Alpine.onBeforeComponentInitialized hook. On top of that come the magic properties $nextTick and $watch, which are not classic lifecycle hooks but are embedded in the lifecycle timeline. A full understanding of the order of these moments is a prerequisite for initializing components correctly and cleaning them up properly.

The most common mistake in practice: developers register event listeners in init() and forget to remove them again in destroy(). This is not a theoretical problem. In Hyva projects that use Turbo or AJAX navigation, components genuinely go through mount and unmount cycles multiple times. Every navigation that remounts a component without destroying the previous one stacks another event listener onto the same window or document event. After ten navigations there are ten listeners, all firing at once. The result is duplicated actions, incremental performance degradation and bugs that are hard to reproduce.

2. The init() hook: timing and DOM access

The init() method inside an x-data object is called automatically by Alpine.js after the component has been initialized and the reactive data system is set up, but before the first render pass happens. That means you can set data, start API calls and register listeners inside init(). What you cannot assume is that x-text, x-show or other directives have already been evaluated and that the corresponding DOM changes are visible. The component's DOM tree does exist, however, and is accessible via this.$refs and this.$el.

An important difference from Vue.js: Alpine.js has no mounted() equivalent that runs after the first render. If you need to be certain that an action happens after the first complete render, use this.$nextTick() inside init(). That is necessary, for example, when you want to measure the height of a rendered element, which is only correct after the first render. Without $nextTick, getBoundingClientRect() still returns the values from the previous state.


// 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 as a directive: when and why

Alongside the init() method in the x-data object there is the x-init directive, which is used directly as an HTML attribute. The difference is not just syntactic: x-init runs after the component has been initialized and has access to all reactive data. It can be placed on any element inside the component, not only the root element. That makes it possible to keep initialization logic close to individual sub-elements instead of collecting everything in the central init() method.

The directive is especially useful for declarative initializations that do not need a JavaScript function: x-init="fetch('/api/products').then(r => r.json()).then(d => products = d)". That is compact and self-documenting. However, x-init has no access to this.$refs, because at the time it is evaluated no DOM rendering has happened yet. For anything that needs DOM access, init() in the x-data object is the right choice, or a combination such as x-init="$nextTick(() => ...)".

4. The destroy() hook: what triggers it and when it runs

The destroy() hook was introduced in Alpine.js 3.x and is called when a component is removed from the DOM. That happens in the following situations: an x-if becomes false, the element is removed from the DOM by JavaScript, or the page is replaced during SPA navigation. In Hyva projects with Turbo navigation (full page caching with AJAX refresh), the destroy() hook matters a great deal, because page transitions remount Alpine components.

Alpine.js calls destroy() synchronously before the element is removed from the DOM. That means you still have access to this.$el and all refs at that point. This moment is ideal for running cleanup actions: removing event listeners, stopping timers, unsubscribing observables, closing connections and releasing resources. If you do not define a destroy() method, you need to make sure the component holds no global resources, which in practice is rarely the case.


// 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: the most common memory leak pattern

The most common memory leak pattern in Alpine.js projects looks like this: window.addEventListener('resize', () => this.handleResize()) gets registered inside init(). The problem here is twofold. First, every call to init() creates a new anonymous function that gets registered as a listener. Second, removeEventListener has no way to unregister that listener again, because there is no reference to the anonymous function. Every remount of the component stacks another listener.

The solution is consistently storing listener references. Every listener registered in init() gets its own instance variable. The underscore-prefix naming convention (this._resizeHandler) signals that these are internal, non-reactive properties. Alpine.js observes properties reactively even when reactivity is not needed, so if you store many listener references it is worth using Alpine.raw(this) to access the non-reactive object directly and avoid proxy overhead.

6. Lifecycle in nested components

When several x-data components are nested inside each other, the lifecycle runs from outside in during initialization and from inside out during destruction. The outer component is initialized first, then the inner one. When removed, the inner component is destroyed first, then the outer one. This matters when components access each other: inside the inner component's init(), the outer component is already fully initialized and its data is available through $store or through scope inheritance.

Alpine.js components inherit the reactive scope of their parent components. That means a child component can access properties of the parent component without an explicit prop hand-off like in Vue or React. This scope inheritance is powerful, but it can lead to unexpected dependencies. If a child component accesses a parent property while the parent component is being destroyed and the child is still active, errors occur. In practice, correct lifecycle management prevents this case.

7. Lifecycle with x-if: mounting components dynamically

x-if in Alpine.js behaves differently from CSS-based x-show: it actually inserts the element into the DOM and removes it again. That means every transition from false to true mounts the component fresh and init() runs again. On the transition from true to false, destroy() is called and the element is removed. That is a clean solution for modals, drawers and other elements that appear and disappear on demand.

The performance implication: every mount of a complex component costs time, DOM creation, reactivity setup, init() logic, API calls. For elements that are shown and hidden very frequently, such as tooltips, x-show is more performant, because it only changes CSS and performs no re-mount. For modals that open rarely and should load fresh data on every open, x-if is the better choice. The lifecycle hook cycle of x-if is therefore not just a detail, it is a design decision.


// 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 {
    products: [],
    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/products', {
          signal: this._abortController.signal
        });
        this.products = 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. Loading and releasing external resources cleanly

External resources in Alpine.js components include, beyond event listeners: setInterval and setTimeout IDs, ResizeObserver and IntersectionObserver instances, WebSocket connections, fetch requests via AbortController, and third-party SDK instances such as map libraries or chart libraries. Every one of these resources needs to be released in destroy(), otherwise leaks occur. The AbortController approach for fetch requests is particularly elegant here: on destroy, controller.abort() is called, which automatically cancels every in-flight fetch request that uses that controller as its signal.

ResizeObserver and IntersectionObserver are commonly used APIs for responsive components, but they cause leaks without a disconnect() call in the destroy hook. The pattern is always the same: store the instance in an instance variable, call observe() in the init() hook, call disconnect() in the destroy() hook. Alpine.js components that use these observers without disconnecting them hold references to DOM elements that no longer exist, which in V8 prevents those elements from being collected by the garbage collector.

9. Lifecycle hooks compared: Alpine vs. Vue vs. React

Lifecycle moment Alpine.js 3.x Vue 3 React (Hooks)
Before first render init() onBeforeMount() useState / useRef init
After first render init() + $nextTick() onMounted() useEffect(fn, [])
After every update $watch() onUpdated() useEffect(fn, [dep])
On removal destroy() onUnmounted() useEffect cleanup fn
Error handling No hook onErrorCaptured() Error Boundary

Alpine.js keeps its lifecycle model deliberately simple. There are no hooks for before/after-update phases of individual properties, and no error-boundary concept. What Alpine.js offers is enough for the vast majority of use cases in Hyva projects. Developers who have complex lifecycle requirements beyond init and destroy typically fall back on $watch, which reacts to individual data values and can run arbitrary callbacks, including cleanup logic when a value changes.

Mironsoft

Alpine.js architecture, Hyva Themes and Magento 2 frontend development

Alpine.js components with a clean lifecycle?

We build Hyva components that leave no memory leaks behind: correct init/destroy implementations, clean event listener management and robust resource release.

Leak analysis

Chrome DevTools memory profiling for existing Alpine.js components

Refactoring

Retrofitting destroy() hooks and listener cleanup into existing components

Architecture

Component structure for Hyva projects with correct lifecycle design from the start

10. Summary

The Alpine.js lifecycle built around init() and destroy() has a simple API but real consequences when misused. init() runs after reactivity setup but before the first render, so DOM measurements need $nextTick. destroy() runs synchronously before the element is removed and must release every global resource: event listeners, observers, timers, open connections. Missing cleanup logic results in memory leaks and stacked listeners that show up for real in projects with dynamic navigation.

The concrete coding pattern: store every listener reference in an instance variable with an underscore prefix. Register it in init(), deregister it in destroy(). Use an AbortController for fetch requests and call abort() on destroy. Use x-if when components need fresh data on every mount, the mount/unmount cycle is then a feature, not a bug. Applied consistently, this pattern turns Alpine.js components in Hyva into robust, maintainable building blocks.

Alpine.js Lifecycle: the essentials at a glance

init() timing

After reactivity setup, before the first render. DOM present but directives not yet evaluated. Use $nextTick() for post-render logic.

destroy() timing

Synchronous, before the element is removed from the DOM. Still has access to $el and $refs. Release all global resources here.

Event listener cleanup

Store the listener reference in this._handler. addEventListener in init(), removeEventListener in destroy(). Anonymous functions cannot be used for cleanup.

x-if vs. x-show

x-if: real mount/unmount, fresh data, lifecycle hooks run. x-show: CSS only, no re-mount, no lifecycle. Choose based on the use case.

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

1Exactly when does init() run in Alpine.js?
After reactivity setup, before the first render. DOM present, directives not yet evaluated. Use $nextTick() inside init() for post-render actions.
2init() vs. x-init, what is the difference?
init() is a method inside the x-data object with full this access and $refs. x-init is an HTML directive for simple initialization expressions without DOM access.
3When is destroy() called?
When x-if becomes false, on direct DOM removal, or on SPA navigation. Runs synchronously before the element is removed, still has access to $el and $refs.
4Why can't anonymous functions be used with removeEventListener?
Every anonymous function is a new instance, removeEventListener cannot find it. Store the reference in this._handler and use it both times.
5How do I measure an element's height in init()?
this.$nextTick(() => { h = this.$refs.el.getBoundingClientRect().height }). Without $nextTick, getBoundingClientRect still returns the previous values.
6Lifecycle in nested components?
Init: outside in. Destroy: inside out. Child components have access to parent data while init() is running.
7x-if and a repeated init() call?
x-if genuinely removes and re-inserts the element. Not a CSS toggle. Every switch to true means a fresh mount with a new init(). Use x-show when no re-mount is wanted.
8Cancel a fetch request in the destroy() hook?
AbortController: init creates the controller, fetch receives the signal, destroy calls abort(). The request fails with an AbortError.
9Disconnect ResizeObserver in destroy()?
Yes, mandatory. Without disconnect(), the observer keeps a reference to the DOM element, preventing garbage collection and causing memory leaks.
10A hook that runs after every render in Alpine.js?
No onUpdated() equivalent. $watch() reacts to individual properties. Alpine.js is deliberately designed around a simple lifecycle API.