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) would be an awkward fit. Itā€™s easier 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.

šŸ‘ˆ Older: A Rubyist learns Haskell, part 3 šŸš€ Back to top