Notes to self

Taking Stimulus.js for a ride

Stimulus.js is one of the new front-end framework on the market. It’s goal is to be a modest JavaScript framework for the HTML you already have, which makes it ideal as an alternative to jQuery or framework-less JavaScript. Let’s see how it feels to replace VanillaJS with Stimulus.js on one concrete example.

Example

Remember my article about building a simple visual select with pure JavaScript? I will use the exact same example and rewrite it to Stimulus.js. Here is the original fiddle for comparison. Note that for today article I will drop the ES5 requirement.

Stimulus.js concepts in a nutshell

In Stimulus.js we write controllers with targets and actions that are automatically connected with HTML elements based on good old data attributes. If we want to implement something with Stimulus we have to mark the related DOM as a controller with controller data attribute.

Every element that should trigger an action will have a data attribute action with the format of “event->controller-name#method” and every element that we need to reference will have a data attribute target with the format of “controller-name.targetName”.

In this case the first example from the project’s GitHub will make things clear:

<div data-controller="hello">
  <input data-target="hello.name" type="text" />
  <button data-action="click->hello#greet">Greet</button>
  <span data-target="hello.output"></span>
</div>

Please refer to the official handbook for details on this philosophy.

Installation

For today’s example I will use CDN which shows how easy is to drop Stimulus.js in any project. If your project uses Webpack or Webpacker, follow Webpack installation guide.

To use Stimulus.js from the CDN it’s enough to include the following <script> tag:

<script src="https://unpkg.com/stimulus/dist/stimulus.umd.js"></script>

HTML

I will start converting the original implementation by updating the HTML with data attributes that Stimulus.js uses for references as follows:

<body>
  <form data-controller="activity">
    <input id="filter" type="text" placeholder="Filter..." data-target="activity.filter" data-action="input->activity#showOrHideOptionButton" />

    <div id="virtual_select" data-target="activity.virtualSelect"></div>

    <select id="source" multiple="multiple" data-target="activity.select">
      <option value="0">chess</option>
      <option value="1">swimming</option>
      <option value="2">ping pong</option>
      <option value="3">running</option>
      <option value="4">canoying</option>
      <option value="5">surfing</option>
    </select>
  </form>
</body>

First I mark what part of DOM will be the controller responsible for. The controller name will be activity. Then I mark the targets which is the input field, virtual select field and the real select. All of the target names are prepended with the controller name. This enables Stimulus to work with more controllers placed all over the DOM.

After that I need to define an action for the filter input field. “input->” denotes that the action triggers on every input change event (as we type) and “activity#showOrHideOptionButton” denotes the controller and method called.

JavaScript

Once the HTML is ready we can start writing the controller itself. Here is the stub of the controller:

<script>
  (() => {
    const application = Stimulus.Application.start()

    application.register("activity", class extends Stimulus.Controller {
      static get targets() {
        return [ "filter", "select", "virtualSelect" ]
      }

      connect() {
      }

      showOrHideOptionButton() {
      }

      addSelectOption(option, element) {
      }

      enableOrDisableOption(event) {
      }

      clearFilterSearch() {
      }
    })
  })()
</script>

I should mention three important things at this point. First is that we need to define our application and register the controllers. Second, for every controller we need to list the targets that we are using in a static method targets(). That’s a convention. Last, every controller can define connect() method that gets called when the controller is “mounted”. And that’s also where I start with the implementation:

connect() {
  const select = this.selectTarget;
  const virtualSelect = this.virtualSelectTarget;

  for (var i = 0; i < select.options.length; i = i + 1) {
    this.addSelectOption(select.options[i], virtualSelect);
  }

  select.style.display = "none";
  this.showOrHideOptionButton();
}

I prepare the virtualSelect, hide the real select and run showOrHideOptionButton that will prepare the clickable links for options. Notice how the targets are actually referenced; always with a “Target” suffix. Then you simply used them as DOM elements as in pure JavaScript.

showOrHideOptionButton() {
  const filter = this.filterTarget;
  const virtualSelect = this.virtualSelectTarget;
  var search = filter.value.toLowerCase();

  for (var i = 0; i < virtualSelect.children.length; i = i + 1) {
    const link = virtualSelect.children[i];

    if (link.innerText.toLowerCase().indexOf(search) >= 0) {
      link.classList.remove("hidden");
    } else {
      link.classList.add("hidden");
    }
  }
}

showOrHideOptionButton implementation is practically same only it’s a controller action rather then function.

addSelectOption(option, element) {
  const virtualSelect = this.virtualSelectTarget;

  const el = document.createElement("a");
  el.href = "#";
  el.dataset.action = "click->activity#enableOrDisableOption";
  el.innerText = option.text;
  el.data

  if(option.selected) {
    el.classList.add("selected");
  }

  virtualSelect.appendChild(el);
}

addSelectOption differs in a way that we are defining an action data attribute rather than onclick event handler. “click->” part defines the action on click.

enableOrDisableOption(event) {
  event.preventDefault();
  event.stopImmediatePropagation();

  const select = this.selectTarget;
  const link = event.target;
  const optionText = link.innerText;

  for (var i = 0; i < select.options.length; i = i + 1) {
    if(select.options[i].text == optionText) {
      if(select.options[i].selected) {
        link.classList.remove("selected");
        select.options[i].selected = false;
      } else {
        link.classList.add("selected");
        select.options[i].selected = true;
      }
    }
  }

  this.clearFilterSearch();
}

Unlike the pure JS implementation enableOrDisableOption action uses the event that gets passed to the action and finding the real target with event.target call. I am also stopping the original click with event.preventDefault() and event.stopImmediatePropagation() which took me a while (see issue #163).

Finally we finish by defining clearFilterSearch action:

clearFilterSearch() {
  const filter = this.filterTarget;
  const virtualSelect = this.virtualSelectTarget;

  filter.value = "";

  for (var i = 0; i < virtualSelect.children.length; i = i + 1) {
    virtualSelect.children[i].classList.remove("hidden");
  }
}

Wiring between JavaScript controllers and the HTML is done automatically for you and works perfectly in tandem with Turbolinks which is very appealing to me.

Result

Here is the final JS fiddle implementing visual select in Stimulus.js. Please share your comments if I made some obvious mistakes regarding Stimulus!

Conclusion

Perhaps you also noticed a lot of similarity in the structure of the controller and my previous functional implementation. It doesn’t really feel that different. In this very example it does not even feel like you would need Stimulus.js.

Nevertheless I like its use of a few conventions to standardize simple JavaScript code that would otherwise get unwieldy soon. It’s almost always good to enforce a structure and in case your project does not really warrant a component approach I can only recommend giving Stimulus.js a go. There is also a package repository for Stimulus.js code, so make sure to check it out.

Check out my book
Deployment from Scratch is unique Linux book about web application deployment. Learn how deployment works from the first principles rather than YAML files of a specific tool.
by Josef Strzibny
RSS