Micro-interactivity without Stimulus

When Turbo itself is more than enough.

The previous article describes my efforts to thwart attacks against www.whoishiring.jobs, so after covering such a frustrating topic it’s time for something lighter. This article describes a simple approach I used for adding minuscule amounts of interactivity using Turbo without Stimulus.

Problem

The original motivation for this approach is right on the home page: the list of most frequently posted keywords. The list defaults to showing the 12 most popular keywords, but can be expanded to show all. This is where some interactivity is needed.

The list of 12 most popular keywords and "Show all 68 keywords" link that should expand the list

I love relying on CSS for that kind of interactivity. I’m using Tailwind CSS so a data attribute in conjunction with styling based on parent state seems like a perfect choice. Let’s start with markup:

<!-- The list of tags is a UL marked with the `group` class, so that descendants
     can query based on attributes defined on UL. Notice it includes
     `data-collapsed` which is used below to hide less popular keywords. -->
<ul id="keywords" class="group" data-collapsed>
  <!-- The list starts with 12 most popular keywords shown unconditionally. -->
  <li><a href="#">Python</a></li>
  <li><!-- ... --></li>

  <!-- Keywords are hidden conditionally: if the group tag (the UL above) has
       the attribute `data-collapsed` then these LIs are hidden. -->
  <li class="group-data-[collapsed]:hidden"><a href="#">Python</a></li>
</ul>

<!-- The link that should make the list expand -->
<a id="show-keywords" href="#">Show all keywords</a>

The markup above makes it simple to expand the list: just remove data-collapsed from the UL. It’s a perfect task for DOM API. Only one problem remains: registering an event handler on the home page.

Solution

The solution relies on the fact that page navigation in Turbo emits turbo:load, so page-specific setup can be performed in that event’s handler. The only missing piece of information is knowing which page we’re looking at.

The click event handler on the link should be installed only when the show action of the home controller is rendered. I decided to inject the controller and action name into the markup via the layout, so that JavaScript can make decisions based on that:

<!-- BODY has a data-endpoint attribute containing the controller and action name -->
<%= tag.body(data: { endpoint: "#{controller_name}##{action_name}" }) do %>
  <!-- Rest of the layout goes here -->
<% end %>

For example, the /jobs path is routed to index in jobs, and its corresponding data-endpoint value is jobs#index. Beware of limitations though: the entire BODY must be updated in order to keep data-endpoint up to date.

With the controller and action name in the markup, an event handler for turbo:load can be registered:

// After every Turbo navigation ...
addEventListener("turbo:load", () => {
  // ... inspect the endpoint attribute and ...
  if (document.body.dataset.endpoint === "home#show") {
    // ... perform the required setup.
  }
})

This is where the list expansion code should live:

addEventListener("turbo:load", () => {
  if (document.body.dataset.endpoint === "home#show") {
    // Get the keywords list.
    const keywords = document.getElementById("keywords")

    // Get the show keywords link.
    const showKeywords = document.getElementById("show-keywords")

    // When the link is clicked ...
    showKeywords.addEventListener("click", (e) => {
      // ... prevent the default action, ...
      e.preventDefault()

      // ... remove data-collapsed to show all keywords, and ...
      delete keywords.dataset.collapsed

      // ... remove the link.
      showKeywords.parentElement.removeChild(showKeywords)
    })
  }
})

Closing Thoughts

I’m definitely not against Stimulus, on the contrary, it’s one of my favorite libraries. However, when the amount of interactivity is minuscule then it may make more sense to rely on CSS and DOM. It’s a truly potent combination that can be used to implement not only expandable lists, but image carousels, tabbed interfaces, page-specific times, and many other small interactions.

More importantly, even after the JavaScript side reaches a level of complexity that warrants the introduction of Stimulus, it should still delegate as much work as possible to CSS with Stimulus being primarily responsible for setup, tear down, and coordination.

Enjoyed the article? Follow me on Twitter!

I regularly post about Ruby, Ruby on Rails, PostgreSQL, and Hotwire.

Leave your email to receive updates about articles.