Boring Rails

Self-destructing StimulusJS controllers

Jun 13th, 2022 6 min read

Sometimes you need a little sprinkle of JavaScript to make a tiny UX improvement. In the olden days, full-stack developers would often drop small jQuery snippets straight into the page:

<script type="application/javascript">
  $(".flash-container").delay(5000).fadeOut();
  $(".items").last().highlight();
</script>

It got the job done, but it wasn’t the best.

In Hotwire apps you can use a “self-destructing” Stimulus controller to achieve the same result.

Self-destructing?

Self-destructing Stimulus controllers run a bit of code and then remove themselves from the DOM by calling this.element.remove().

Let’s see an example:

// app/javascript/controllers/scroll_to_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static values = {
    location: String,
  };

  connect() {
    this.targetElement.scrollIntoView();
    this.element.remove();
  }

  get targetElement() {
    return document.getElementById(this.locationValue);
  }
}

This controller takes in a location value and then scrolls the page to show that element.

<template
  data-controller="scroll-to"
  data-scroll-to-location-value="task_12345"></template>

For self-destructing controllers, I like to use the <template> tag since it will not be displayed in the browser and is a good signal when reading the code that this isn’t just an empty div.

This pattern works really well with Turbo Stream responses.

Imagine you have a list of task with an inline form to create a new task. You can submit the form and then send back a <turbo-stream> to append to the list and then scroll the page to the newly created task.

<%= turbo_stream.append :tasks, @task %>

<%= turbo_stream.append :tasks do %>
  <template
    data-controller="scroll-to"
    data-scroll-to-location-value="<% dom_id(@task) %>"></template>
<% end %>

And because we wrap our small bit of JavaScript functionality in a Stimulus controller, all of the lifecycle events are taken care of. No need to listen for turbo:load events, it just works.

What else could you use this for?

Highlighter

We use this highlighter controller to add extra styles when something is “selected”.

Example of highlighter controller

<template
  data-controller="highlighter"
  data-highlighter-marker-value="<%= dom_id(task, :list_item) %>"
  data-highlighter-highlight-class="text-blue-600 bg-blue-100"
></template>

By using both the Stimulus values and classes APIs, this controller is super reusable: we can specify any DOM element id and whatever classes we want to use to highlight the element.

// app/javascript/controllers/highlighter_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static values = {
    marker: String,
  };
  static classes = ["highlight"];

  connect() {
    this.markedElement.classList.add(...this.highlightClasses);
    this.element.remove();
  }

  get markedElement() {
    return document.getElementById(this.markerValue);
  }
}

Grab focus

We use a grab-focus controller for a form where you can quickly add tasks. Submitting the form creates the task and then dynamically adds a new <form> for the next task. This controller seamlessly moves the browser focus to the new input.

Example of grab-focus controller

// app/javascript/controllers/grab_focus_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static values = {
    selector: String,
  };

  connect() {
    this.grabFocus();
    this.element.remove();
  }

  grabFocus() {
    if (this.hasSelectorValue) {
      document.querySelector(this.selectorValue)?.focus();
    }
  }
}

Analytics “Beacons”

We borrowed this idea from HEY and use it for tracking page analytics. We add a beacon to the page that pings the backend to record a page view and then removes itself.

(If you’re fancy you could even use the Beacon Web API, but we’re justing sending an PATCH request here for simplicity!)

// app/javascript/controllers/beacon_controller.js
import { Controller } from "@hotwired/stimulus";
import { patch } from "@rails/request.js";

export default class extends Controller {
  static values = { url: String };

  connect() {
    patch(this.urlValue);
    this.element.remove();
  }
}

We wrapped this one up in a Rails view helper for a more clean API.

module AnalyticsHelper
  def tracking_beacon(url:)
    tag.template data: { controller: "beacon", beacon_url_value: url }
  end
end
<!-- Inside app/views/layouts/plan.html.erb -->
<%= tracking_beacon(url: plan_viewings_path(@plan)) %>

Wrap it up

Self-destructing Stimulus controllers are a great way to augment Hotwire applications by adding sprinkles of JavaScript behavior without having to completely eject and build the whole feature on the client-side. Keep them small and single-purpose and you’ll be able to reuse them across pages and in different contexts.

Piggybacking on the existing lifecycle of Stimulus controllers ensures that things work as expected when changing content via Turbo Streams and navigating between pages with Turbo Drive.

Was this article valuable? Subscribe to the low-volume, high-signal newsletter. No spam. All killer, no filler.