Writing better Stimulus controllers

  • 时间: 2020-06-05 06:33:38

We write a lot of JavaScript at Basecamp, but we don’t use it to create “JavaScript applications” in the contemporary sense. All our applications have server-side rendered HTML at their core, then add sprinkles of JavaScript to make them sparkle. - DHH

In early 2018, Basecamp released StimulusJS into the world . Stimulus closed the loop on the “Basecamp-style” of building Rails applications.

It’s hard to pin down a name for this stack, but the basic approach is a vanilla Rails app with server-rendered views, Turbolinks (“HTML-over-the-wire”, pjax ) for snappy page loads, and finally, Stimulus to “sprinkle” interactive behavior on top of your boring old HTML pages.

Many of the tenets of Basecamp and DHH’s approach to building software weave in-and-out of this stack:

And frankly, the most compelling to me: the tradition of extracting code from real-world products (and not trying to lecture birds how to fly ).

I’m excited to see more refinement of this stack as Basecamp prepares to launch HEY .

In the coming months, we should see the release of Stimulus 2.0 to sharpen the APIs, a reboot of Server-generated JavaScript Responses ( SJR ), and a splash of web-sockets to snap everything together.

These techniques are extremely powerful, but require seeing the whole picture. Folks looking to dive into this stack (and style of development) will feel the “Rails as a Sharp Knife” metaphor more so than usual.

But I’ve been in the kitchen for a while and will help you make nice julienne cuts (and not slice off your thumb).

Server-rendered views in Rails are a known path. Turbolinks, with a few caveats, is pretty much a drop-in and go tool these days.

So today, I’ll be focusing on how to write better Stimulus controllers .

This article is explicitly not an introduction to Stimulus. The official documentation and Handbook are excellent resources that I will not be repeating here.

And if you’ve never written any Stimulus controllers, the lessons I want to share here may not sink in right away. I know because they didn’t sink in for me!

It took 18 months of living full-time in a codebase using this stack before things started clicking. Hopefully, I can help cut down that time for you. Let’s begin!

What may go wrong

The common failure paths I’ve seen when getting started with Stimulus:

Making controllers too specific (either via naming or functionality)

It’s tempting to start out writing one-to-one Stimulus controllers for each page or section where you want JavaScript. Especially if you’ve used React or Vue for your entire application view-layer. This is generally not the best way to go with Stimulus.

It will be hard to write beautifully composable controllers when you first start. That’s okay.

Trying to write React in Stimulus

Stimulus is not React. React is not Stimulus. Stimulus works best when we let the server do the rendering. There is no virtual DOM or reactive updating or passing “data down, actions up”.

Those patterns are not wrong, just different and trying to shoehorn them into a Turbolinks/Stimulus setup will not work.

Growing pains weaning off jQuery

Writing idiomatic ES6 can be a stumbling block for people coming from the old days of jQuery.

The native language has grown leaps and bounds, but you’ll still scratch your head from time to time wondering if people really think that:

new Array(...this.element.querySelectorAll(".item"));

is an improvement on $('.item') . (I’m right there with you, but I digress… )

How to write better Stimulus controllers

After taking Stimulus for a test drive and making a mess, I revisited the Handbook and suddenly I saw the examples in a whole new light.

For instance, the Handbook shows an example for lazy loading HTML:

<div data-controller="content-loader" data-content-loader-url="/messages.html">  Loading...</div>

Notice the use of data-content-loader-url to pass in the URL to lazily load.

The key idea here is that you aren’t making a MessageList component. You are making a generic async loading component that can render any provided URL.

Instead of the mental model of extracting page components, you go up a level and build “primitives” that you can glue together across multiple uses.

You could use this same controller to lazy load a section of a page, or each tab in a tab group, or in a server-fetched modal when hovering over a link.

You can see real-world examples of this technique on sites like GitHub.

(Note that GitHub does not use Stimulus directly, but the concept is identical)

The GitHub activity feed first loads the shell of the page and then uses makes an AJAX call that fetches more HTML to inject into the page.

<!-- Snippet from github.com --><div class="js-dashboard-deferred" data-src="/dashboard-feed" data-priority="0">  ...</div>

GitHub uses the same deferred loading technique for the “hover cards” across the site.

<!-- Snippet from github.com --><a  data-hovercard-type="user"  data-hovercard-url="/users/swanson/hovercard"  href="/swanson">  swanson</a>

By making general-purpose controllers, you start the see the true power of Stimulus.

Level one is an opinionated, more modern version of jQuery on("click") functions.

Level two is a set of “behaviors” that you can use to quickly build out interactive sprinkles throughout your app.

Example: toggling classes

One of the first Stimulus controllers you’ll write is a “toggle” or “show/hide” controller. You’re yearning for the simpler times of wiring up a click event to call $(el).hide() .

Your implementation will look something like this:

// toggle_controller.jsimport { Controller } from "stimulus";export default class extends Controller {  static targets = ["content"];  toggle() {    this.contentTarget.classList.toggle("hidden");  }}

And you would use it like so:

<div data-controller="toggle">  <button data-action="toggle#toggle">Toggle</button>  <div data-target="toggle.content">    Some special content  </div></div>

To apply the lessons about building more configurable components that the Handbook recommends, rework the controller to not hard-code the CSS class to toggle.

This will become even more apparent in the upcoming Stimulus 2.0 release when “classes” have a dedicated API.

// toggle_controller.jsimport { Controller } from "stimulus";export default class extends Controller {  static targets = ["content"];  toggle() {    this.contentTargets.forEach((t) => t.classList.toggle(data.get("class")));  }}

The controller now supports multiple targets and a configurable CSS class to toggle.

You’ll need to update the usage to:

<div data-controller="toggle" data-toggle-class="hidden">  <button data-action="toggle#toggle">Toggle</button>  <div data-target="toggle.content">    Some special content  </div></div>

This might seem unnecessary on first glance, but as you find more places to use this behavior, you may want a different class to be toggled.

Consider the case when you also needed some basic tabs to switch between content.

<div data-controller="toggle" data-toggle-class="active">  <div    class="tab active"    data-action="click->toggle#toggle"    data-target="toggle.content"  >    Tab One  </div>  <div    class="tab"    data-action="click->toggle#toggle"    data-target="toggle.content"  >    Tab Two  </div></div>

You can use the same code. New feature, but no new JavaScript! The dream!

Example: filtering a list of results

Let’s work through another common example: filtering a list of results by specific fields.

In this case, users want to filter a list of shoes by brand, price, or color.

We’ll write a controller to take the input values and append them to the current URL as query parameters.

Base URL: /app/shoesFiltered URL: /app/shoes?brand=nike&price=100&color=6

This URL scheme makes it really easy to filter the results on the backend with Rails.

// filters_controller.jsimport { Controller } from "stimulus";export default class extends Controller {  static targets = ["brand", "price", "color"];  filter() {    const url = `${window.location.pathname}?${this.params}`;    Turbolinks.clearCache();    Turbolinks.visit(url);  }  get params() {    return [this.brand, this.price, this.color].join("&");  }  get brand() {    return `brand=${this.brandTarget.value}`;  }  get price() {    return `price=${this.priceTarget.value}`;  }  get color() {    return `color=${this.colorTarget.value}`;  }}

This will work, but it’s not reusable outside of this page. If we want to apply the same type of filtering to a table of Orders or Users, we would have to make separate controllers.

Instead, change the controller to handle arbitrary inputs and it can be reused in both places – especially since the inputs tags already have the name attribute needed to construct the query params.

// filters_controller.jsimport { Controller } from "stimulus";export default class extends Controller {  static targets = ["filter"];  filter() {    const url = `${window.location.pathname}?${this.params}`;    Turbolinks.clearCache();    Turbolinks.visit(url);  }  get params() {    return this.filterTargets.map((t) => `${t.name}=${t.value}`).join("&");  }}

Example: lists of checkboxes

We’ve seen how to make controllers more reusable by passing in values and using generic targets. One other way is to use optional targets in your controllers.

Imagine you need to build a checkbox_list_controller to allow a user to check all (or none) of a list of checkboxes. Additionally, it needs an optional count target to display the number of selected items.

You can use the has[Name]Target attribute to check for if the target exists and then conditionally take some action.

// checkbox_list_controller.jsimport { Controller } from "stimulus";export default class extends Controller {  static targets = ["count"];  connect() {    this.setCount();  }  checkAll() {    this.setAllCheckboxes(true);    this.setCount();  }  checkNone() {    this.setAllCheckboxes(false);    this.setCount();  }  onChecked() {    this.setCount();  }  setAllCheckboxes(checked) {    this.checkboxes.forEach((el) => {      const checkbox = el;      if (!checkbox.disabled) {        checkbox.checked = checked;      }    });  }  setCount() {    if (this.hasCountTarget) {      const count = this.selectedCheckboxes.length;      this.countTarget.innerHTML = `${count} selected`;    }  }  get selectedCheckboxes() {    return this.checkboxes.filter((c) => c.checked);  }  get checkboxes() {    return new Array(...this.element.querySelectorAll("input[type=checkbox]"));  }}

Here we can use the controller to add “Check All” and “Check None” functionality to a basic form.

We can use the same code to build a checkbox filter that displays the count of the number of selections and a “Clear filter” button (“check none”).

As with the other examples, you can see the power of creating Stimulus controllers that can be used in multiple contexts.

Putting it all together: composing multiple controllers

We can combine all three controllers to build a highly interactive multi-select checkbox filter.

Here is a rundown of how it all works together:

  • Use the toggle_controller to show or hide the color filter options when clicking the input

  • Use the checkbox_list_controller to keep the count of selected colors and add a “Clear filter” option

  • Use the filters_controller to update the URL when filter inputs change, for both basic HTML inputs and our multi-select filter

Each individual controller is simple and easy to implement but they can be combined to create more complicated behaviors.

Here is the full markup for this example.

<div class="filter-section">  <div class="filters" data-controller="filters">    <div>      <div class="filter-label">Brand</div>      <%= select_tag :brand,            options_from_collection_for_select(              Shoe.brands, :to_s, :to_s, params[:brand]            ),            include_blank: "All Brands",            class: "form-select",            data: { action: "filters#filter", target: "filters.filter" } %>    </div>    <div>      <div class="filter-label">Price Range</div>      <%= select_tag :price,            options_for_select(              [ ["Under $100", 100], ["Under $200", 200] ], params[:price]            ),            include_blank: "Any Price",            class: "form-select",            data: { action: "filters#filter", target: "filters.filter" } %>    </div>    <div>      <div class="filter-label">Colorway</div>      <div class="relative"        data-controller="toggle checkbox-list"      >        <button class="form-select text-left"          data-action="toggle#toggle"          data-target="checkbox-list.count"        >          All        </button>        <div class="hidden select-popup" data-target="toggle.content">          <div class="flex flex-col">            <div class="select-popup-header">              <div class="select-label">Select colorways...</div>              <button class="clear-filters"                data-action="checkbox-list#checkNone filters#filter"              >                Clear filter              </button>            </div>            <div class="select-popup-list space-y-2">              <% Shoe.colors.each do |c| %>                <%= label_tag nil, class: "leading-none flex items-center" do %>                  <%= check_box_tag 'colors[]', c, params.fetch(:colors, []).include?(c),                    class: "form-checkbox text-indigo-500 mr-2",                    data: { target: "filters.filter"} %>                  <%= c %>                <% end %>              <% end %>            </div>            <div class="select-popup-action-footer">              <button class="p-2 w-full select-none"                data-action="filters#filter"              >                Apply              </button>            </div>          </div>        </div>      </div>    </div>  </div></div>

Wrap it up

Stimulus works best when it’s used to add sprinkles of behavior to your existing HTML. Since Rails and Turbolinks are super effective at handling server-rendered HTML, these tools are a natural fit.

Using Stimulus requires a change in mindset from both jQuery snippets and React/Vue. Think about adding behaviors, not about making full-fledged components.

You’ll avoid the common stumbling blocks with Stimulus if you can make your controllers small, concise, and re-usable.

You can compose multiple Stimulus controllers together to mix-and-match functionality and create more complex interactions.

These techniques can be difficult to wrap your head around, but you can end up building highly interactive apps without writing much app-specific JavaScript at all!

It’s an exciting time as this stack evolves, more people find success with shipping software quickly, and it becomes a more known alternative to the “all-in on JavaScript SPA” approach.

Additional Resources