Taking the most out of Stimulus.js

Stimulus.js is a modest JavaScript framework for the HTML we already have. It doesn’t aspire to take over the front-end, but it’s there to help you add a few JS sprinkles when needed. It is also really useful to wrap external libraries and let the HTML be the source of truth for the front-end.

Here are some guidelines to get the most out of Stimulus.js in our apps.

Prefer general-purpose controllers

It might be tempting to use Stimulus on a per-page/per-resource-controller basis, but we should use general-purpose controllers as a rule of thumb.

For instance, imagine we have a Rails PinsController. In the show action, we want to be able to copy a pin to the clipboard. So, we might write:

// ❌ Don't
class PinsController extends Controller {
  copyPIN() {
    const text = document.querySelector('#user-pin').value;
    navigator.clipboard.writeText(text)
  }
}

There’s nothing specific to this behavior that we need to limit it just to this (hypothetical) Pins page. With a few changes, we could make it work for any page.

// βœ… Do
class ClipboardController extends Controller {
  static targets = ['source'];

  copy() {
    navigator.clipboard.writeText(this.sourceTarget.value);
  }
}

With that in mind, it’s helpful to think in terms of behaviors in our apps. Which kinds of behaviors do we want them to have? Examples of behaviors are showing/hiding/toggling elements in the screen, focusing on inputs, sorting rows in a table, displaying a tooltip or modal, etc. Create controllers and actions for each behavior.

Following this sort of Unix philosophy really makes Stimulus shine. Once we get a few controllers, it will be possible to build new features by composing the current existing behaviors!

Compose controllers with events

Say we have a controller that copies the value of an input to the clipboard:

<script>
  class ClipboardController extends Controller {
    static targets = ['source'];

    copy() {
      navigator.clipboard.writeText(this.sourceTarget.value);
    }
  }
</script>

<div data-controller="clipboard">
  <label>
    PIN:
    <input type="text" name="pin" data-clipboard-target="source" readonly />
  </label>
  <button data-action="clipboard#copy">Copy to clipboard</button>
</div>

Imagine that, in some places, we want to add a flash message saying “Copied!” when the copy action happens. We could change the clipboard controller to also display flash messages, but that feels wrong. We might already have a controller that displays flash messages, so how can we compose them? Instead of reaching out for something like inheritance, or duplicating code, we can use events.

class ClipboardController extends Controller {
  static targets = ['source'];

  copy() {
    navigator.clipboard.writeText(this.sourceTarget.value);
    this.dispatch('copy', {detail: {content: this.sourceTarget.value}});
  }
}

And now, we wire the two controllers together in HTML:

<div data-controller="clipboard flash" data-action="clipboard:copy->flash#show" data-flash-message-param="Copied!">
  <!-- πŸ‘† Note the data-action in this line -->
  <label>
    PIN:
    <input type="text" name="pin" data-clipboard-target="source" readonly />
  </label>
  <button data-action="clipboard#copy">Copy to clipboard</button>
</div>

With this approach, we not only avoided violating the single-responsibility principle, but we were able to compose behaviors without writing new code.

Wrap external libraries in controllers

Sometimes we might use external libraries to build a particular feature. For example, we might want to use Tippy.js to add tooltips to elements on the page.

The naive approach would be writing something like:

<button id="some-button">Hover me</button>

<script>
  const button = document.getElementById('some-button');
  tippy(button, {
    content: "I'm a tooltip!",
  });
</script>

It’s good to separate the behavior itself from its implementation. So, we can wrap this in a Stimulus controller:

class TooltipController extends Controller {
  static values = {message: String}; // We could receive a config object here, to customize the tooltip

  connect() {
    this.tippyInstance = tippy(this.element, this.messageValue);
  }

  disconnect() {
    this.tippyInstance.destroy();
  }
}

And the HTML would look like this:

<button data-controller="tooltip" data-tooltip-message-value="I'm a tooltip!">Hover me</button>

This might seem a bit verbose, but it doesn’t tie the implementation to one specific library. If we ever need to swap the underlying tooltip package, we’ll have to change that in a single place! Yay! πŸŽ‰

Use the values API to customize behavior

Say we have a Stimulus controller to debounce events:

import {Controller} from '@hotwired/stimulus';
import debounced from 'debounced';

export default class extends Controller {
  initialize() {
    debounced.initialize({input: {wait: 250}});
  }
}

What if we want to debounce a different event or change the wait time? Fortunately, Stimulus.js lets us customize our controllers with the values API.

<script>
  import {Controller} from '@hotwired/stimulus';
  import debounced from 'debounced';

  export default class extends Controller {
    static values = {config: Object};

    initialize() {
      debounced.initialize(this.configValue);
    }
  }
</script>

<label>
  A debounced input
  <input
    data-controller="debounced"
    data-debounced-config-value="{input: {wait: 250}}"
    data-action="debounced:input->controller#doSomething"
  />
</label>

And if we want, it’s even possible to provide a default value!

<script>
  import {Controller} from '@hotwired/stimulus';
  import debounced from 'debounced';

  export default class extends Controller {
    static values = {
      config: {type: Object, default: {input: {wait: 250}}}, // πŸ‘ˆ default value
    };

    initialize() {
      debounced.initialize(this.configValue);
    }
  }
</script>

<label>
  A debounced input
  <input
    id="debounced-input"
    data-controller="debounced"
    data-action="debounced:input->controller#doSomething"
  />
</label>

Using the Values API to customize controllers is a powerful way to make them more flexible and reusable.

Prefer using actions to manually adding event listeners

Imagine a controller that allows us to trap focus within a particular HTML element. Here’s a possible implementation:

<script>
  // ❌ Don't
  import {Controller} from '@hotwired/stimulus';

  export default class extends Controller {
    connect() {
      this.element.addEventListener('toggle', this.trapFocus);
    }

    disconnect() {
      this.element.removeEventListener('toggle', this.trapFocus);
    }

    trapFocus = event => {
      // ...
    };
  }
</script>

<details data-controller="menu">
  <summary>Open the menu</summary>
  <!-- ... -->
</details>

While this works, manually adding event listeners is an unnecessary pain. With actions we can achieve the same thing with less code!

<script>
  // βœ… Do
  import {Controller} from '@hotwired/stimulus';

  export default class extends Controller {
    trapFocus(event) {
      // ...
    }
  }
</script>

<details data-controller="menu" data-action="toggle->menu#trapFocus">
  <!-- πŸ‘† Note the data-action in this line -->
  <summary>Open the menu</summary>
  <!-- ... -->
</details>

Furthermore, using actions to annotate the HTML helps us communicate the app’s intention and behavior.

Decouple JS and CSS with the Classes API

We often have to change CSS classes from elements via JS. The problem is that whenever we change those classes, we need to update the HTML and the Stimulus controllers that use them. This might not be obvious when refactoring, and we might end up with deprecated classes in the JS code!

So, instead of doing something like this:

// app/javascript/controllers/toggler_controller.js
import {Controller} from '@hotwired/stimulus';

export default class extends Controller {
  static targets = ['element'];

  show() {
    this.elementTarget.classList.remove('hidden');
  }

  hide() {
    this.elementTarget.classList.add('hidden');
  }
}

We can use Stimulus Classes API to refer to CSS classes:

// app/javascript/controllers/toggler_controller.js
// βœ… Do
import {Controller} from '@hotwired/stimulus';

export default class extends Controller {
  static targets = ['element'];
  static classes = ['hidden'];

  show() {
    this.elementTarget.classList.remove(this.hiddenClass);
  }

  hide() {
    this.elementTarget.classList.add(this.hiddenClass);
  }
}

And the template would look like this:

<div data-controller="toggler" data-toggler-hidden-class="some-component--hidden">
  <span class="some-component" data-toggler-target="element">This toggles!</span>
  <button data-action="toggler#show">Show</button>
  <button data-action="toggler#hide">Hide</button>
</div>

This technique is particularly beneficial when using a utility-first CSS library like Tailwind.

// βœ… Do
// app/javascript/controllers/toggler-controller.js
import {Controller} from '@hotwired/stimulus';

export default class extends Controller {
  static targets = ['element'];
  static classes = ['hidden'];

  show() {
    this.elementTarget.classList.remove(...this.hiddenClasses);
  }

  hide() {
    this.elementTarget.classList.add(...this.hiddenClasses);
  }
}

Stick with Vanilla.js

The fewer libraries we depend on, the easier are upgrades and refactors. Several projects depend on jQuery, for instance. While transitioning to Hotwire, we might be able to rewrite some code not to rely on external libraries. Modern JavaScript has many goodies and excellent browser support, so let’s take advantage of that!

Here’s a compilation of pure JS alternatives to jQuery: https://youmightnotneedjquery.com.

It’s just sprinkles

Remember that Turbo is still the go-to approach to building reactive applications in Rails. Stimulus is just the last 10-20% of the way. You might not need JS at all! Make sure the way you’re using it reflects that philosophy.