Noel Rappin Writes Here

Okay, This One Is About Stimulus

Posted on January 11, 2021


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->visibility#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 VisiblityController. 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 change 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="toChange">
    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. 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="toChange">
    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.

Here 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="toChange">
    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.



Comments

comments powered by Disqus



Copyright 2024 Noel Rappin

All opinions and thoughts expressed or shared in this article or post are my own and are independent of and should not be attributed to my current employer, Chime Financial, Inc., or its subsidiaries.