Alpine.js as a Stimulus alternative

How to avoid inline JS with Alpine CSP

November 4, 2024 Ā· Felipe Vogel Ā·

Recently I discovered Alpine.js as an alternative to Stimulus for conveniently sprinkling JavaScript into server-rendered pages—and it may even be a better alternative.

You may know of Alpine as that little JS library where you write inline JS in the HTML—ewww! That’s all I knew about it too.

But it turns out that you can put the JS in separate files, as in Stimulus, and you can even prohibit inline JS with the Alpine CSP build. But I’m getting ahead of myself…

First, the context

At my job, I work on a fairly vanilla Rails app. One of its oddities, though, is that it uses Stimulus and not Turbo, its sibling library in the Hotwire suite.

Below I go into why we don’t use Turbo, but the point here is that Stimulus is currently our only tool for creating real-time UI functionality, and it hurts. No one at work really likes Stimulus, I think because we’re expecting too much from it and using it in ways it wasn’t designed for. (More on that below.)

Some teams are pushing to migrate the app to Angular. I’m doubtful whether that would be worth the effort, so I’m looking into Alpine to see if it would give us some of the conveniences of a framework like Angular, but without the huge migration and added complexity.

Why not Turbo?

I mentioned that our app at work doesn’t use Turbo. A Git log search (git log -S) reveals that the developers back in 2016 removed Turbo’s predecessor Turbolinks from the app, with commit messages like Get your stupid Turbolinks outta my house šŸ˜‚

Then, starting around the time Turbo was released in 2020, teams were rearranged and the customer-facing part of the app stopped being seriously worked on, until my team was formed earlier this year. During that interval, there wasn’t much impetus to look for something better than just Stimulus.

Also, we heavily use Lit web components from our in-house design system in a way that Turbo (with its server-centric mindset) might be an awkward fit. It makes sense for us to use a JS library that plays nicely with web components by operating on the client side, as Stimulus does.

Why not web components?

The previous paragraph begs the question, Why not just build your features in Lit?

It’s because I, along with the rest of my team, enjoy keeping Rails view templates server-side. Let’s say I want to add real-time behavior to a view: if I have to move an ERB template into a Lit component and translate it into JS, it feels like I’ve left the realm of ā€œJS sprinklesā€ and now it’s more like ā€œJS chunksā€.

If that feels like a meaningless distinction to you, and if you find Lit components to be perfectly usable in day-to-day feature work, then go ahead and use Lit instead of Stimulus or Alpine! I honestly wish I didn’t feel as much friction as I do when making Lit components, because Lit is probably the more durable option since it’s closer to web standards than Alpine.

Who knows, maybe in a few months I’ll write a post titled ā€œWeb components as an Alpine.js alternativeā€ šŸ˜‚ I’ve seen efforts underway to more easily server-render web components (and not just in Node), so it may someday be possible to declare a web component within a server-side template such as ERB, using regular HTML with special attributes for the dynamic bits. In fact, I was looking for such an approach to web components when I ran across a comment saying that’s precisely what Alpine does, minus the web components part.

All that to say, my thoughts below are subject to change when web components become easier for this use case focused on non-Node SSR, or ā€œkeep your templates and sprinkle in dynamic behaviorā€.

Stimulus is incomplete on its own

My gripe with Stimulus is that it’s imperative, not declarative. In most modern JS front-end libraries (React, Angular, Lit, etc.), you have a template that is automatically re-rendered based on changes to state that’s stored in JS. But with Stimulus, as with jQuery, state is expected to be stored in the DOM, and you have to manually change the DOM in response to events. It gets tedious fast.

But here’s the thing: Stimulus was intentionally designed this way so that it would work well alongside Turbo. To quote the Stimulus Handbook:

Stimulus also differs on the question of state. Most frameworks have ways of maintaining state within JavaScript objects, and then render HTML based on that state. Stimulus is the exact opposite. State is stored in the HTML, so that controllers can be discarded between page changes [such as HTML fragments morphed in via Turbo], but still reinitialize as they were when the cached HTML appears again.

In other words, Stimulus discourages storing state in JS because that state disappears whenever an element with an attached Stimulus controller is replaced by Turbo.

Consequently, Stimulus wasn’t designed for building elaborate front-end features. Turbo does the heavy lifting, and Stimulus handles the leftover bits: small, generic components or behaviors. See the examples in the Stimulus Handbook (a copy button and a slideshow), advice in articles on Stimulus (1, 2), and open-source Stimulus controllers.

… But we don’t use Turbo at work, and for the reasons I gave earlier it would be complicated to use Turbo in our app. So I thought a better starting point would be to find a ā€œJS sprinklesā€ library that’s more capable on its own than Stimulus.

Alpine.js

Alpine is like Stimulus!

Alpine.js is similar to Stimulus in several ways:

In fact, you can use Alpine in a way very similar to Stimulus. Using the mappings below, you can write JS with Alpine that looks the same as Stimulus, apart from different syntax and naming:

So Alpine is more or less a superset of Stimulus.*

* Note: this is not true if you’re using Stimulus specifically for its being a convenient wrapper around the MutationObserver API. I couldn’t find a good example of what that looks like with Stimulus, but one scenario where you need MutationObserver is to make a web component react to DOM changes (1, 2).

Alpine is NOT like Stimulus!

Alpine goes beyond Stimulus in that it’s fundamentally declarative.

Stimulus is limited to granular DOM manipulation, as in ā€œon X event add a ā€˜hidden’ class to elements A and B; on Y event remove the ā€˜hidden’ class from elements A and Bā€

But Alpine allows a declarative style, as in ā€œshow elements A and B when Z is trueā€ (see x-show).

This shift from imperative to declarative style can make your JS so much more readable, maintainable, and bug-resistant. As an experiment, at work I did a Stimulus-to-Alpine conversion of an ā€œEdit Mailing Addressā€ form built around an in-house web component for address autocomplete. The web component emits events that my JS captures and translates into error messages on the form. I was shocked at the difference:

Examples

I can’t actually show that example from work, so rather than making up my own new examples here, I’ll just link to the two examples in Brian Schiller’s post ā€œAlpine.js vs Stimulusā€, and I’ll add my own alternate Alpine versions that keep the JS out of the HTML.

In my versions of the examples, I used the CSP build of Alpine, where inline JS is actually impossible. This nicely serves as a form of no-inline-JS linting. It also introduces some inconveniences, but they can be worked around:

Example 1: toggle menu

This is Brian Schiller’s second example, but I’m putting it first because it’s the simpler of the two.

My notes: The HTML is cleanest with Alpine CSP, and the JS file is still super short.

Example 2: filterable list

My notes:

But what if I need Turbo?

Earlier I pointed out that the apparent shortcomings of Stimulus are by design, in order for it to be compatible with Turbo. Does that mean you can’t use Alpine with Turbo?

Yes and no. If you’ve already built your app using Stimulus and Turbo, it might not make sense to switch from Stimulus to Alpine. Turbo is probably handling most of the real-time interactivity anyway.

But if all you want is something like Turbo, i.e. a way to take HTML fragments sent from the server and morph them into the page, here are Alpine-friendly options for you:

I’d probably pick Alpine AJAX, but more importantly I wouldn’t reach for morphing without considering other approaches first, because morphing is complex and has limitations and edge cases. For example:

Conclusion

I’ll piggyback on Brian’s post one more time and repeat its conclusion here:

I can enthusiastically choose Alpine over Stimulus. Stimulus seems to have skipped the lessons of the past 7-8 years. It’s better than jQuery, but ignores the things that are good about React, Vue, etc: reactivity and declarative rendering. Meanwhile, Alpine manages to pack a ton of useful functionality into a smaller bundle size than Stimulus.

Alpine’s bundle size has now grown somewhat larger than that of Stimulus, but otherwise I wholeheartedly agree with that conclusion.

And I’ll add this: for anyone who is concerned about the maintainability of inline JS in HTML and prefers clean markup, a clear separation of concerns, TypeScript, etc. … you can do all that with Alpine! I think Alpine is the best of both worlds, between ā€œall-inā€ JS frameworks that handle rendering (React et al.) and vanilla JS: declarative and template-friendly like React et al., but at the same time, small and easy to sprinkle into your server-rendered templates.

Bonus: Alpine plugins

Another nice thing about Alpine is how many plugins there are out there. I already mentioned Alpine AJAX; here are just a few others:

You can find lots more in lists like Alpine Extensions and Awesome Alpine Plugins.

šŸ‘‰ Newer: How I use MacOS, Linux, Windows, iOS, Android šŸ‘ˆ Older: A Rubyist learns Haskell, part 3 šŸš€ Back to top