A few years ago I wrote about why Stimulus irritates me and how Vue can do the same thing, but better. It got me thinking about other tools that do a better job at being “JavaScript for the HTML you already have.” Two of them are Alpine.js and htmx.
Alpine and htmx take different approaches:
- Alpine is for client-side reactivity—state that lives in the browser
- htmx is for server interactions—fetching HTML from your backend
Both are better than Stimulus at what they do. And when you need both approaches? They work together to do a better job than Stimulus.
Let me show you with examples taken from the Stimulus documentation itself.
This is the example from the Stimulus homepage. You type a name, click a button, and see a greeting.
1 2 3 4 5 6 | <div data-controller="hello"> <input data-hello-target="name" type="text"> <button data-action="click->hello#greet">Greet</button> <span data-hello-target="output"></span> </div> |
1 2 3 4 5 6 7 8 9 10 | import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "name", "output" ] greet() { this.outputTarget.textContent = `Hello, ${this.nameTarget.value}!` } } |
To understand what happens when you click the button, you have to:
- Find the controller name in the HTML
- Find the corresponding JavaScript file
- Map the action syntax to a method
- Understand the all of the naming conventions
Compare that to the Alpine version:
1 2 3 4 5 6 | <div x-data="{ name: '' }"> <input x-model="name" type="text"> <button>Greet</button> <span x-text="name ? `Hello, ${name}!` : ''"></span> </div> |
No separate file. No imports. No target lookups. The behavior is right there in the HTML. I can easily put it directly here in this article:
This is the second example from the Stimulus handbook. It loads HTML from the server and optionally refreshes it on an interval.
1 2 3 4 | <div data-controller="content-loader" data-content-loader-url-value="/messages.html" data-content-loader-refresh-interval-value="5000"></div> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | import { Controller } from "@hotwired/stimulus" export default class extends Controller { static values = { url: String, refreshInterval: Number } connect() { this.load() if (this.hasRefreshIntervalValue) { this.startRefreshing() } } disconnect() { this.stopRefreshing() } load() { fetch(this.urlValue) .then(response => response.text()) .then(html => this.element.innerHTML = html) } startRefreshing() { this.refreshTimer = setInterval(() => { this.load() }, this.refreshIntervalValue) } stopRefreshing() { if (this.refreshTimer) { clearInterval(this.refreshTimer) } } } |
That’s 30+ lines of JavaScript to periodically fetch some HTML. You have to manage lifecycle hooks, timers, and cleanup.
Compare it to the htmx version:
1 2 | <div hx-get="/messages.html" hx-trigger="load, every 5s"></div> |
htmx handles the fetch, the interval, and the cleanup automatically.
Want navigation between different content sources?
1 2 3 4 5 6 7 8 | <nav> <button hx-get="/demos/messages.html" hx-target="#content">Messages</button> <button hx-get="/demos/tasks.html" hx-target="#content">Comments</button> </nav> <div id="content"> <p>Click a button to load content</p> </div> |
I can put the above code example directly in this article:
Click a button to load content
Sometimes you need both client-side state and server interaction. A modal dialog with a form is a classic example: showing/hiding the modal is client-side state, but the form submission goes to the server.
With Stimulus you’d need either one complex controller or two controllers that somehow communicate with each other (which Stimulus isn’t good at). You’d wire up the modal toggle, handle the form submission with fetch, parse the response, update the DOM, and close the modal on success.
I’m not going to code it up because it would be 80+ lines across multiple files and Stimulus sucks.
The Alpine with htmx example is around 15 lines of HTML:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | <div x-data="{ open: false }"> <button @click="open = true">Subscribe</button> <div x-show="open" x-cloak class="modal-backdrop" @click.self="open = false"> <div class="modal"> <h2>Subscribe to Updates</h2> <form hx-post="/subscribe" hx-target="#result" @htmx:after-request="open = false"> <input type="email" name="email" placeholder="you@example.com" required> <button type="submit">Subscribe</button> </form> <div id="result"></div> <button @click="open = false">Cancel</button> </div> </div> </div> |
Alpine handles the modal state. htmx handles the form submission. They communicate through the @htmx:after-request event—when htmx finishes, Alpine closes the modal.
No JavaScript files. No lifecycle management. No manual DOM manipulation.
Check it out:
(The form won't actually submit because this is a static blog, but the modal interaction works.)
Stimulus claims to be “a modest JavaScript framework for the HTML you already have.” But look at the examples:
- The
StimulusHTML is littered with framework-specific attributes that point elsewhere - The behavior lives in separate files with their own conventions
- Simple tasks require a lot of ceremony: controllers, targets, actions, values, and lifecycle hooks
Alpine and htmx actually deliver on the promise:
- The behavior is visible in the HTML itself
- You can understand what happens without opening another file
- Simple tasks are simple
The best part? You don’t have to choose. Alpine handles client-side state. htmx handles server interactions. Use whichever one fits the problem or use them together.