Okay, This One Is About Stimulus
Previously On Locally Sourced: I wrote about Hotwire and Turbo, the Rails client side New Magic. Then I wrote about them again. I think you are all caught up.
I've been writing about Hotwire and Turbo, and haven't said all that much here about Stimulus. Which is a tool that I like so much, that after using it on a project, I literally decided to write a book so I could tell more people about it.
What do I like about it? It has a lot of the same virtues as Turbo: it's very easy to incrementally add onto a page, and has basically no carrying costs for the parts of a page that don't need it. It has a very low number of concepts to learn to get moving, and you can start doing interesting things with very small amounts of code. It's very flexible, much more flexible than you might expect from just reading about it, and it lends itself to writing generic tools in a very interesting way.
Let me give a very high-level overview of how Stimulus works.
Stimulus is integrated with your pages through adding data
attributes to your HTML. The Stimulus system sits in the background, watches for DOM changes that affect those data
attributes and uses those changes to create listeners that tie actions that you specify to Stimulus code. The data
attributes are also used to carry information from HTML to Stimulus.
The main unit of Stimulus is the controller. You connect a DOM element to a Stimulus controller by making the name of that controller the value of a data-controller
attribute on the element. Within the controller, you can use other HTML attributes to specify DOM that actions should invoke specific methods on the controller. You can also use HTML attributes to identify elements as "targets" of the controller, which means the controller can find them with a getter method, and other HTML attributes can pass arbitrary values to the controller from the HTML data.
Features of Stimulus:
It's very small, with only a handful of concepts, which themselves are quite small.
It's quite succinct, you can do meaningful things with a stimulus controller with very small amounts of JavaScript
Because it watches behind the scenes, Stimulus is very good in a situation where items are being added and removed from the DOM regularly. Like, say via Turbo.
Your interaction with Stimulus is via HTML data attributes which is both really flexible and also really explicit, especially given how much convention over configuration is going on.
The idea is that you have small Stimulus controllers that do one thing, and then you can compose them by attaching multiple controllers to the same element. This does make the HTML verbose, but I find it really clean in practice. Where I've gotten in trouble with Stimulus, it's often been because I was making my controller too complex, and simplifying and splitting helped a ton.
Ultimately, Stimulus lets you create really general JavaScript utilities that allow you to add features to your site just in HTML, without writing new custom JavaScript. You don't by default create custom HTML elements the way turbo-frame
does, but you can similarly add a lot of power to a site just by annotating the HTML.
Let me show you what I mean. Here's a simple Stimulus controller that toggles the visibility of a target based on an action. (This is a less-detailed version of the Stimulus example in the book.
The HTML here is simplified, I've cleared out styling and other details for clarity... It's from the show/hide button that you can see in the Turbo examples, but I've cleared out non-essential details.
Given some HTML...
<div data-controller="visibility">
Header Text
<span data-action="click->visiblity#toggle">
Hide
</span>
<div data-visibility-target="toHide">
Body Text
</div>
</div>
We've got an outer div that is using data-controller=visibilty
to tie itself to a Stimulus controller, which by convention is named VisibilityController
. Inside that div
, we have a span that declares using data-action
that when the click
event happens, Stimulus should call the toggle
method of the visibility
controller. Below that, the other div with the body text, declares itself a target named toHide
of the parent controller.
The JavaScript for the controller looks like this...
import { Controller } from "stimulus"
export default class VisibilityController extends Controller {
static targets = ["toHide"]
toggle(): {
this.toHideTarget.classList.toggle("hidden")
}
}
The controller declares that it has a toHide
target, for which Stimulus automatically declares a toHideTarget
property which contains the element with the attribute data-visibility-target="toHide"
(there's a slightly different mechanism if you expect more than one target to be declared with the same name).
What's happening here is that the toggle
action gets called by Stimulus when the click
event happens. In that method, we call that toHideTarget
property and toggle its class list to be hidden or not, causing that div
with the body text to get the hidden
class added or remove to it's CSS class list causing it to appear or not.
That's a pretty compact way of managing this toggle, all we need to do is identify the pieces in the puzzle, write one line of actual code logic, and Stimulus puts the puzzle together according to consistent rules.
Okay, we can take this a little farther. Let's add some underlying state, rather than just toggling the DOM class.
A slight HTML change, adding one new data-visiblity-visible-value
attribute:
<div data-controller="visibility"
data-visibility-visible-value="true">
Header Text
<span data-action="click->visiblity#toggle">
Hide
</span>
<div data-visibility-target="toHide">
Body Text
</div>
</div>
And a slightly more complex controller:
import { Controller } from "stimulus"
export default class VisibilityController extends Controller {
static targets = ["toHide"]
static values = { visible: Boolean }
toggle(): {
this.flipVisibility()
}
flipVisibility() {
this.visibleValue = !this.visibleValue
}
visibleValueChanged() {
this.toHideTarget.classList.toggle(
"hidden",
!this.visibleValue
)
}
}
The controller now declares a value, visible
, with a Boolean
type -- the type declaration only affects how the value in the HTML is type cast before use. With that declaration, the controller grows a visibleValue
getter which extracts that value from the HTML dataset and casts it to type. There's also a visibleValue=
setter. We also get a visibleValueChanged
hook that is automatically invoked whenever the DOM inspector sees that the data attribute data-visibility-visible-value
changes. One might say the hook method is invoked in, well, reaction to that change...
Our control flow has changed slightly. When the user invokes the click action, the toggle
method is called, and all that method does is call the property setter to change the value of the underlying property, which updates the DOM. The DOM update triggers the visibleValueChanged
method, which is where the actual DOM classes get changed.
This version is slightly more complex, but has two advantages. First, we can set the initial state of the toggle by setting the data attribute in the initial HTML -- a Stimulus controller calls all of its ValueChanged
callbacks when it is created. Second, any time that data attribute changes for any reason -- not just from Stimulus -- it also triggers the callback. You can go into the console, grab the element and set data-visibility-visible-value
there, and the target will show or hide in response.
That gives us more power and flexibility, but we can go even further -- Stimulus has a mechanism for dealing with CSS classes as data attributes (really, a special case of the values mechanism) and we can make a totally generic CSS changing controller:
import { Controller } from "stimulus"
export default class CssController extends Controller {
static classes = ["css"]
static targets = ["toChange"]
static values = { status: Boolean }
toggle() {
this.flipState()
}
flipState(): void {
this.statusValue = !this.statusValue
}
statusValueChanged(): void {
this.updateCssClass()
}
updateCssClass(): void {
this.toChangeTarget.classList.toggle(
this.cssClass,
this.statusValue
)
}
}
The HTML does get a little more verbose:
<div data-controller="css"
data-css-status-value="false"
data-css-css-class="hidden">
Header Text
<span data-action="click->css#toggle">
Hide
</span>
<div data-css-target="toHide">
Body Text
</div>
</div>
Now, the hidden
CSS class name comes from the HTML data, not the controller.
At this point, we now have a generic controller that toggles a CSS class on a target element based on an action. There are all kinds of common bits we can do from this -- add animation on a hover event, make a checkbox-like element add a border on a click event, and so on. We can do all this without writing any more JavaScript, just by annotating the HTML and using this CssController
.
Stimulus works very well for small controllers that do one thing and can be composed. In this example, the target shows and hides, but the button text stays the same.
Changing an element's text based on an action seems like something we could also write a generic Stimulus controller to do. In fact, it's very similar to what we already have:
import { Controller } from "stimulus"
export default class TextController extends Controller {
static targets = ["withText"]
static values = {
status: Boolean,
on: String,
off: String
}
toggle(): void {
this.flipState()
}
flipState(): void {
this.statusValue = !this.statusValue
}
statusValueChanged(): void {
this.updateText()
}
newText(): string {
return this.statusValue ? this.onValue : this.offValue
}
updateText(): void {
this.elementWithTextTarget.innerText = this.newText()
}
}
At this point, I might also consider moving the flipState
functionality to a mixin or parent class, but that's beside the point. The point is that we can now do a simple text change from annotated HTML. Again, it gets a little verbose here, but I kind of like it:
<div data-controller="css"
data-css-status-value="false"
data-css-css-class="hidden">
Header Text
<span data-controller="text"
data-action="click->css#toggle click->text#toggle"
data-text-target="withText"
data-text-status-value="false"
data-text-off-value="Hide"
data-text-on-value="Show">
</span>
<div data-css-target="toHide">
Body Text
</div>
</div>
In this snippet, there's a second controller that only surrounds the button, called text
. It has its own status value, data-text-status-value
and also defines the text for the on and off states of the button with data attributes. It defines itself as the withText
target of its own controller. The data-action
for the button now defines two actions, toggling the CSS controller and toggling the text controller. You are guaranteed that the actions will happen in the order of the text. Now, clicking on that button triggers both actions, causing both controllers to flip their status value and causing both of their callback functions to be called, changing the CSS of the toHide
target and the text of the withText
target. (Note the text target doesn’t need to also specify text inside the tag, the controller puts the initial text there on load based on the status value.)
And look, there are lots of ways to polish this. The CSS controller needs to be able to handle multiple CSS classes at once. It feels weird to have both controllers maintain their own status variables. This doesn't deal really well if you need to nest two of these controllers.
But those are all harder cases. One thing about the Hotwire tools is that they make the common cases easy without making anything else less complicated.
I've written very little JavaScript, and there are now a lot of easy cases where I can add interaction to my site without writing any more JavaScript. And nothing else on my site has gotten any more complex other than this one snippet of HTML.
Now that you've gotten near the end, lets take this too far. If you really like the Turbo custom HTML element aesthetic, you can do this:
export class CssControllerElement extends HTMLElement {
constructor() {
super()
}
connectedCallback() {
this.dataset.controller = "css"
}
}
customElements.define("css-controller", CssControllerElement)
export class TextControllerElement extends HTMLElement {
constructor() {
super()
}
connectedCallback() {
this.dataset.controller = "text"
}
}
customElements.define("text-controller", TextControllerElement)
I've defined a custom HTML element for each controller. When the element is connected, it sets it's own data-controller
attribute, which causes Stimulus to notice it just as though the data-controller
element had been set in the HTML. Now we can use those custom elements directly:
<css-controller data-css-status-value="false"
data-css-css-class="hidden">
Header Text
<text-controller
data-action="click->css#toggle click->text#toggle"
data-text-target="withText"
data-text-status-value="false"
data-text-off-value="Hide"
data-text-on-value="Show">
</text-controller>
<div data-css-target="toHide">
Body Text
</div>
</css-controller>
The css-controller
custom element is taking the place of div data-controller="css"
, and text-controller
is replacing div data-controller="text"
.
This works, the Stimulus controllers are attached to the custom elements, and everything else behaves the way it did in the previous example.
Would I use that? Probably not in general, one thing I like about Stimulus is the way it feels like annotating HTML, and a lot of custom elements seems like it moves away from that and might be confusing. Also, one nice feature of Stimulus is that you can attach multiple controllers to the same element, which a custom element also moves away from. I might use it occasionally for a Stimulus controller that really is affecting its body enough to want that extra emphasis. A controller that was sorting it's internal elements maybe?
So that's a quick tour of Stimulus and what I like about it. For more details, I've heard there's a book.
Dynamic Ruby is brought to you by Noel Rappin.
Comments and archive at noelrappin.com, or contact me at noelrap@ruby.social on Mastodon or @noelrappin.com on Bluesky.
To support this newsletter, subscribe by following one of these two links: