Managing Alpine.js Lifecycle with Catalyst UI (No-Bundler ES6 Setup)

Solve Alpine.js timing issues when using Tailwind's Catalyst UI components in a no-bundler ES6 environment. Learn how to properly orchestrate component loading and registration to prevent initialization failures.

Context and challenges

Pairing Alpine.js with Tailwind’s Catalyst UI Kit in a plain ES6 setup (no bundler) creates a timing problem that is easy to hit and annoying to debug. Catalyst UI Kit is a library of pre-built Tailwind components aimed mainly at React projects. In my case I wanted those components in a static HTML/ES6 page, with Alpine.js handling interactivity instead of React. That means treating Catalyst’s components as static HTML and CSS, then defining Alpine “components” through Alpine.data to drive their behaviour.

The trouble starts because Alpine initialises the moment it loads, scanning the DOM for directives. When my Alpine components (the modules that export Alpine.data definitions for Catalyst UI behaviours) load asynchronously, Alpine can start processing the DOM before any of them are registered. The result is missing functionality or outright errors, since Alpine has no idea what the x-data references point to.

The timing mismatch is the whole problem. Dynamic ES6 module imports are asynchronous, but Alpine’s DOM initialisation runs synchronously once triggered. The usual lifecycle hooks (alpine:init or x-init) do not fix the sequencing, because neither can pause Alpine to wait for async code. The alpine:init event does fire before Alpine touches the DOM, which is fine for synchronous setup, but any async work you kick off there (a dynamic import(), say) will not finish before Alpine carries on. The x-init hook runs per component during initialisation, which is already too late to load a new component definition.

So I needed a way to load and register every Alpine component before Alpine starts processing the DOM, knowing that Alpine will not wait for module imports on its own.

Lifecycle management strategy overview

The fix is to take manual control of Alpine’s startup so it holds off until every component module has loaded and registered. In practice that comes down to three things.

First, load Alpine.js in a way that does not auto-start, so I get to decide when Alpine.start() runs. Rather than the standard CDN script that auto-initialises, I use Alpine’s ES6 module build (or intercept the auto-init) to stop it from processing the DOM straight away. That opens a window to register components first.

Next, import or pre-load all the Alpine component modules (the ES6 files that define Alpine.data for the Catalyst UI components) before calling Alpine.start(). This makes sure every x-data component name is known to Alpine ahead of time.

Then register each component with Alpine using Alpine.data(name, definition) as it loads. Only once registration is finished do I trigger Alpine’s DOM initialisation, which guarantees Alpine recognises every component during its normal startup traversal.

The point is to orchestrate Alpine’s lifecycle on purpose, holding back DOM initialisation until the modules are ready. This matches Alpine’s recommended pattern for modular or bundled setups: import everything, register the components, then start Alpine. In a typical bundler scenario you would do exactly this in your entry file. Here I am doing it by hand in the browser with module scripts.

The official Alpine docs confirm that with a build step or module imports you should register components via Alpine.data(...) before calling Alpine.start(). That way any <div x-data="myComponent"> in the HTML finds a matching definition at initialisation.

Why not use Alpine’s built-in lifecycle events alone?

Alpine v3 gives you global events like alpine:init (fired just before Alpine starts) and alpine:initialized (after it finishes). They are handy for running setup code, but they do not delay Alpine’s startup. You might try something like this:

document.addEventListener("alpine:init", () => {
  // import component module (async)
  import("./components/hamburger-menu.js").then((mod) =>
    Alpine.data("hamburgerMenu", mod.default)
  );
});

The import is asynchronous, so Alpine will not wait for it. The alpine:init handler returns, Alpine initialises the DOM right away, and if the component has not registered yet, any x-data="hamburgerMenu" usage fails to bind.

So alpine:init is the right hook for synchronous setup (calling Alpine.data with already-loaded code), but it cannot solve async loading timing. An x-init on an element cannot load its own component code in time either, because it runs after Alpine has already bound the element’s data.

The only reliable answer is to control when Alpine.start() happens, so it fires after the dynamic imports finish.

Step-by-step implementation guide

Here is the sequence I use to get the lifecycle management right in this architecture.

1. Include Alpine.js in “deferred” mode

Load Alpine’s script so it does not auto-initialise. The simplest route is Alpine’s ESM build via a module script. Download or import the ESM version (for example alpinejs/dist/module.esm.js) and load it with <script type="module">. In that script, import Alpine and attach it to the global scope (window.Alpine = Alpine) but hold off on Alpine.start(). Because modules are deferred by default, the code runs after HTML parsing, leaving Alpine loaded but not started.

HTML setup:

<head>
  <!-- Other head content -->
  <style>
    [x-cloak] {
      display: none !important;
    }
  </style>
  <!-- Load Alpine.js bootstrap configuration -->
  <script type="module" defer src="/js/alpine-bootstrap.js"></script>
</head>

Bootstrap script (alpine-bootstrap.js), step 1:

import Alpine from "/js/vendor/alpine.esm.js";

// Make Alpine available globally but don't auto-start
window.Alpine = Alpine;

// Don't call Alpine.start() yet - we'll do this after component registration

If you have to use the CDN UMD build instead, you can block immediate initialisation by defining a window.deferLoadingAlpine hook before the Alpine script is included. Alpine’s CDN build checks for that hook and, when it finds one, uses it to wrap the internal start function. Assign

window.deferLoadingAlpine = (startAlpine) => { store startAlpine and call later }

before loading Alpine. The effect is the same: Alpine loads but does not run until you call the stored startAlpine() callback.

2. Load all Alpine component modules (asynchronously)

Make sure all your Alpine component scripts (the modules for the Catalyst UI components or any custom Alpine data components) are fetched and evaluated before Alpine starts. If you know the full set of components up front, use static imports at the top of your module script. The browser resolves these during module initialisation, before the rest of the script runs, so they behave like a blocking load for our purposes.

Component registry (alpine/components/index.js):

import { createUniversalModalComponent } from "./universal-modal.js";
import { createHamburgerMenuComponent } from "./hamburger-menu.js";

export const components = {
  universalModal: createUniversalModalComponent,
  hamburgerMenu: createHamburgerMenuComponent,
};

Bootstrap script (alpine-bootstrap.js), step 2:

import { components } from "./alpine/components/index.js";
import Alpine from "/js/vendor/alpine.esm.js";

// Make Alpine available globally but don't auto-start
window.Alpine = Alpine;

// Components are now loaded via static import
// Next: register them with Alpine (Step 3)

Often you will want to import only certain components dynamically, based on which ones appear on the page or to keep the initial load lean. Dynamic imports (import('modulePath')) return a promise, so they are asynchronous. To handle several, fire all the imports in parallel and wait for them together: gather an array of import promises and pass it to Promise.all() so you know when they have all loaded.

One thing to watch: this step has to finish before Alpine initialises. I guarantee that by only calling Alpine.start() once the promise resolves, in the next step.

3. Register components with Alpine

As each module loads, register it with Alpine.data(name, definition). Each module usually exports a default function, the component’s data initialiser. If hamburger-menu.js exports export default () => ({ /*...*/ }), then after importing you call Alpine.data('hamburgerMenu', hamburgerMenuModule.default).

You can register the moment each import resolves, or collect every definition and register them in a batch. Either way, by the end of this step Alpine knows about all your component names.

Bootstrap script (alpine-bootstrap.js), step 3:

import { components } from "./alpine/components/index.js";
import Alpine from "/js/vendor/alpine.esm.js";

// Make Alpine available globally but don't auto-start
window.Alpine = Alpine;

// Register all components before Alpine starts
function registerAlpineComponents() {
  try {
    Object.entries(components).forEach(([name, componentFactory]) => {
      Alpine.data(name, componentFactory);
    });
    return true;
  } catch (error) {
    console.error("Error registering Alpine components:", error);
    return false;
  }
}

const registrationSuccess = registerAlpineComponents();
// Next: start Alpine (Step 4)

With static imports you just call Alpine.data for each one in turn, as above. With dynamic imports via Promise.all, it looks more like this:

Promise.all([
  import("./hamburger-menu.js"),
  import("./universal-modal.js"),
]).then(([hamburgerMod, modalMod]) => {
  Alpine.data("hamburgerMenu", hamburgerMod.default);
  Alpine.data("universalModal", modalMod.default);
  // Now all components are registered...
  Alpine.start();
});

Registration happens before Alpine.start() here. If you are using the window.deferLoadingAlpine approach, call the captured startAlpine() callback at this point instead of Alpine.start() directly.

4. Start Alpine after registration

Now trigger Alpine’s DOM initialisation by calling Alpine.start(). In the ES6 module approach you call it explicitly once the imports are done. If you set up window.deferLoadingAlpine, you call the stored startAlpine() function instead, which calls Alpine.start() for you.

Bootstrap script (alpine-bootstrap.js), step 4 (complete):

import { components } from "./alpine/components/index.js";
import Alpine from "/js/vendor/alpine.esm.js";

// Make Alpine available globally but don't auto-start
window.Alpine = Alpine;

// Register all components before Alpine starts
function registerAlpineComponents() {
  try {
    Object.entries(components).forEach(([name, componentFactory]) => {
      Alpine.data(name, componentFactory);
    });
    return true;
  } catch (error) {
    console.error("Error registering Alpine components:", error);
    return false;
  }
}

// Configure and start Alpine with components
const registrationSuccess = registerAlpineComponents();

if (registrationSuccess) {
  Alpine.start();
} else {
  console.error("Failed to register components, not starting Alpine.js");
}

// Optional: Run code after Alpine is fully initialized
document.addEventListener("alpine:initialized", () => {
  console.log("Alpine.js is ready with all components registered");
});

The whole thing hinges on that call happening only after every component is in place. Once it runs, Alpine walks the DOM and instantiates any x-data components it finds using the definitions we registered. Because we held back startup, Alpine recognises the custom component names from Catalyst UI (x-data="hamburgerMenu" or x-data="universalModal"), since those were defined in step 3.

Alpine’s own lifecycle events fire as normal. The alpine:init event would have gone out just before initialisation (we slipped our loading logic into that gap), and alpine:initialized fires after Alpine finishes setting up all components. At that point any x-init or init() functions inside your Alpine components run too, at the start of each component’s initialisation, which is handy for final setup.

The page is now fully interactive, with the Catalyst UI markup driven by Alpine.js.

Lifecycle flow visualisation

These sequence diagrams show the difference between normal Alpine initialisation and the deferred approach.

Normal Alpine initialization (problematic)

Our deferred initialization (solution)

It all comes down to timing. The deferred approach loads and registers components before Alpine processes the DOM, which avoids the binding failures you get with normal Alpine auto-initialisation.

Additional considerations and best practices

Placement of scripts

Put the module script that orchestrates this at the right spot in your HTML. Usually that means placing the <script type="module"> at the end of the <body> (or adding defer to the script tag) so the DOM is fully parsed before Alpine starts. Load Alpine itself with defer (or as an import in that module) so it does not run until parsing is done. Since Alpine’s start is invoked manually at the end, the DOM is certainly ready by then.

Static vs dynamic imports

If performance allows, static imports for all your component modules keep things simple. The browser fetches them in parallel and runs them synchronously. Static imports resolve during the module’s evaluation phase, blocking until they are loaded, so by the time your script calls Alpine.start() every component is already in memory.

If you would rather load dynamically (maybe loading components conditionally based on page content), you need to work out which components to pull in. One approach is to scan the DOM for occurrences of x-data="hamburgerMenu" or x-data="universalModal" and import those modules dynamically before starting Alpine.

Whatever you do, all the necessary imports have to finish before you call start(). Call Alpine.start() too early and Alpine misses any component that has not registered yet, so always wait for the import promises to resolve with await or .then.

Using Alpine’s global events

With this strategy you may not need document.addEventListener('alpine:init') at all, since you are orchestrating the init by hand. You can still use alpine:initialized to run code right after Alpine finishes initialising the page (logging, or extra setup once every component is live). Just attach that listener before calling Alpine.start() so it catches the event.

Catalyst UI integration

Catalyst UI Kit components were built as React components, so using them with Alpine means re-implementing the interactive logic. The modular Alpine components stand in for the React behaviour, handling things like toggling hamburger menus and modals through Alpine’s reactivity.

Keep each component self-contained in its own module, returning the state and methods it needs. Then follow the loading strategy above so Alpine knows about it. You will probably also need to strip or replace React-specific attributes in the Catalyst HTML snippets, for example swapping React event handlers for Alpine’s x-on directives.

Using components in HTML:

<body class="bg-gray-50 text-gray-900 min-h-screen">
  <div id="app" class="container mx-auto px-4 py-8" x-cloak>
    <!-- Alpine.js Hamburger Menu with Catalyst Navigation Pattern -->
    <div x-data="hamburgerMenu()" @keydown.escape.window="handleEscape($event)">
      <!-- Catalyst UI markup with Alpine directives -->
    </div>

    <!-- Universal Modal Component -->
    <div x-data="universalModal()">
      <!-- Modal content -->
    </div>
  </div>
</body>

Alpine handles UI state for menus, dialogs and form components perfectly well, so you can reach feature parity with the Catalyst kit by writing small Alpine.data components for each UI element.

Testing the timing

It is worth checking the setup in development. Simulate a slow network, or add a deliberate delay to one component module, to confirm Alpine really does wait. If everything is wired correctly, Alpine prints no errors about unknown components and every x-data instantiates properly.

If you do see an Alpine error about an unknown component, Alpine.start() ran before that component registered. Check the promise chain or the script order.

Conclusion

Controlling Alpine’s initialisation lifecycle is what lets you run Alpine.js with Tailwind Catalyst UI components in a no-bundler ES6 environment. The trick is to load and register every Alpine component before Alpine does its work on the DOM.

In practice that means importing Alpine as a module, dynamically importing your component modules, registering them with Alpine.data(...), and only then calling Alpine.start(). That order guarantees Alpine recognises every x-data component when it walks the DOM, which is what clears up the async timing issue.

The approach follows Alpine’s official guidance for modular usage and lines up with community examples of dynamic loading. Wire it up this way and your Catalyst UI components behave as expected with Alpine, even without a bundler.