Bringing Stimulus-like Convenience to Alpine.js

Adam McCrea headshot

Adam McCrea

@adamlogic

It’s no secret that I love Alpine. As part of my evangelism, I want to make sure it’s a drop-in replacement for Stimulus in the Hotwire stack—particularly in the Rails community.

👀 Note

Alpine.js is a lightweight JavaScript framework for composing behavior on top of server-generated HTML—just like Stimulus! Check out my video for a quick intro.

A small sticking point for me has been the extraction and organization of Alpine components. Stimulus provides clear guidance and tooling here: Put your controllers in app/javascript/controllers, and they’ll be auto-loaded when you need them. I’ve wanted a similar convention and tooling for my Alpine components. I just implemented it, and I love it! Maybe you’ll love it, too.

What’s an Alpine component?

Technically, an Alpine component is created whenever you use the x-data attribute. That’s not really what I’m talking about here. You can get a lot done with simple directives thrown into your HTML (and it’s why I love Alpine), but sometimes a component gets too complex for simple directives, and it deserves to live in it’s own dedicated JavaScript file.

In Alpine, you define these reuseable components with the Alpine.data method. (I’m just calling them “components” moving forward.) Here’s a truncated version of our Alpine component for highlighting the current section in our table of contents (it’s over 👉 there if you’re on a wide enough screen).

Alpine.data("tableOfContentsHighlighter", () => ({
  init() {
    const tocLinks = Array.from(this.$el.querySelectorAll('a[href^="#"]'));

    this.linksByHeading = new Map(
      tocLinks
        .map((link) => {
          const id = link.getAttribute("href").substring(1);
          const heading = document.getElementById(id);
          return heading ? [heading, link] : null;
        })
        .filter(Boolean)
    );

    this.onScroll();
  },

  onScroll() {
    // Add "current" class to the first heading above the midline of the viewport.
  },
}));

To see the full thing, “view source” on this site!

IMO that’s way too much JavaScript to throw into an x-data attribute. We could put it in a <script> tag with the HTML, but that doesn’t sit well with me, either. I want reliable syntax highlighting and editor tooling when I’m working on the component, and I want all of my reusable Alpine components to live together like a happy family.

Our convention for Alpine components

Our convention is very simple: All Alpine components live in app/javascript/components. We don’t have a lot of these—again, most of the time we can inline our JS because Alpine is so friggin’ powerful and succinct—so we don’t try to organize any deeper than this. If you make a new component, just throw it in app/javascript/components. Simple!

Then you just have to make sure to import that file in application.js and wrap the component in document.addEventListener('alpine:init'). Every dang time.

What?!!

The Stimulus people don’t have to do that, and neither should we. I want to drop in a new component and have it Just Work.

Fortunately, it doesn’t take much code to auto-import our Alpine components.

Thank you, Stimulus

I had no idea how I would do this until reading through the Stimulus source. They’re parsing the importmap JSON to find all of the JS files in app/javascript/controllers. Brilliant!

importmap in HTML

We can do the same thing to auto-import our Alpine components!

const importmap = JSON.parse(document.querySelector("script[type=importmap]").text);
const paths = Object.keys(importmap.imports).filter((path) => path.match(/^components\/.*/));

paths.map((path) => import(path));

Put that in app/javascript/application.js, and it’ll avoid needing to import each component file individually, but what about that pesky document.addEventListener('alpine:init’)?

It turns out we never really needed that anyway. 🤦‍♂️ It’s only necessary when putting Alpine components in a <script> tag. As long as we’re calling Alpine.start() after we’ve imported and defined our components, we’re fine without the event listener.

import Alpine from "alpinejs";
import "./components/tableOfContentsHighlighter";
window.Alpine = Alpine;
Alpine.start();

But that brings us to a little gotcha…

Static vs. dynamic imports

A static import like import "./components/tableOfContentsHighlighter" is synchronous. Technically it happens at compile time instead of runtime. Since we’re using importmaps without a JS build process, that means it happens during a module resolution and loading step by the browser JavaScript engine. Either way, it’s synchronous, that’s what’s important.

A dynamic import like import(path) is asynchronous. It’s a function as opposed to a static import statement. And because it’s async, we can’t just call Alpine.start() after it. We need to wait for promises to resolve.

Promise.all(paths.map((path) => import(path))).then(() => Alpine.start());

The final solution (for now)

Here’s the entirety of our auto-loading code:

// application.js

import Alpine from "alpinejs";
window.Alpine = Alpine;

// Find all Alpine components in app/javascript/components
const importmap = JSON.parse(document.querySelector("script[type=importmap]").text);
const paths = Object.keys(importmap.imports).filter((path) => path.match(/^components\/.*/));

// Eager load Alpine components first, then start Alpine _afterwards_
Promise.all(paths.map((path) => import(path))).then(() => Alpine.start());

That’s it! Now we can have the “Just Works” experience from Stimulus, but we get to use Alpine instead.

This is working great for us because we have relatively few components. Stimulus also supports lazy loading, but for us it’s not worth the effort to make that happen. Eager loading is super simple and fast enough when there’s not dozens of components.

Feel free to steal this and repurpose it in your own app (we stole half of it from Stimulus, after all) and let us know how it goes!