Alpine.js as a Stimulus alternative
How to avoid inline JS with Alpine CSP
November 4, 2024 Ā· Felipe Vogel Ā·- First, the context
- Why not Turbo?
- Why not web components?
- Stimulus is incomplete on its own
- Alpine.js
- Examples
- But what if I need Turbo?
- Conclusion
- Bonus: Alpine plugins
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:
- Itās small. The bundle sizes of Stimulus and Alpine, respectively, are 10.9 kB and 15.2 kB.
- Itās SSR-friendly. Both Alpine and Stimulus are used in server-rendered templates via special HTML attributes.
- The JS can be in separate files. This isnāt obvious from the Alpine docs, where almost all of the examples have JS written inline in HTML attributes. But as weāll see in the examples below, the JS can be put in its own separate files just as in Stimulus.
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:
x-ref
is like Stimulus targetsx-on
is like Stimulus actions- Where Stimulus has lifecycle callbacks, Alpine has these:
- Iāve never used Stimulus outlets, so I canāt confidently say whether they can be emulated in Alpine. But you probably wouldnāt need them.
- Plain data attributes can stand in for Stimulus values. Or if data attributes become cumbersome, see my examples below for an
x-props
custom directive in Alpine. x-bind
for Stimulus CSS classes.
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:
- The Stimulus controller was 207 lines long, filled with logic that was difficult for me to follow even though Iād written it myself just a few weeks agoāand writing it had been a nightmare of continually finding yet another bug in my code.
- The JS class for the Alpine component totalled only 60 lines, was easier to write, and is actually possible to understand at a glance.
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:
- Passing initial parameters into a data object is impossible. No big deal: you can just use data attributes or (if that becomes clunky) a custom
x-props
directive, which you can see in my version of the second example below. -
x-model
doesnāt work in the CSP build. The workaround is to use the two directives thatx-model
is a shortcut for:<!-- instead of this: --> <input x-model:"myProperty"> <!-- do this: --> <input :value="myProperty" @input="setMyProperty">
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.
- Brianās Stimulus version: CodePen, Brianās notes
- Brianās Alpine version: CodePen, Brianās notes
- My Alpine CSP version: CodePen
My notes: The HTML is cleanest with Alpine CSP, and the JS file is still super short.
Example 2: filterable list
- Brianās Stimulus version: CodePen, Brianās notes
- Brianās Alpine version: CodePen, Brianās notes
- My Alpine CSP version: CodePen
My notes:
- Once again the HTML is cleanest with Alpine CSP, but the JS file is not so satisfyingly short this time. Partly thatās due to how this example is more complex, but another reason is that I threw in the
x-props
custom Alpine directive, not because it was needed but just for the sake of example. - I didnāt love that I had to pull in an extra package to access a parent componentās data.
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:
- htmx, which has an alpine-morph extension.
- Alpine AJAX, which has a similar morph feature.
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:
- a critique of htmx and the htmx authorās reply that itās a work in progress
- a similar critique of Turbo
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:
- The āpluginsā nav section in the Alpine docs
- Form validation and others by Mark Mead, especially the HyperJS collection
- Requests
- Clipboard and others by Ryan Chandler
- Autosize
- Auto animate
x-include
andx-interpolate
You can find lots more in lists like Alpine Extensions and Awesome Alpine Plugins.