DEV Community

matt swanson
matt swanson

Posted on • Originally published at boringrails.com on

Self-destructing StimulusJS controllers

This post is part of Hotwire Summer: a new season of content on Boring Rails!

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>
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<!-- Inside app/views/layouts/plan.html.erb -->
<%= tracking_beacon(url: plan_viewings_path(@plan)) %>

Enter fullscreen mode Exit fullscreen mode

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.


Top comments (1)

Collapse
 
bennadel profile image
Ben Nadel

I really like the idea of using <template> elements as a hook, especially because they can be placed in specific parts of the DOM with the Turbo Streams. It would be interesting to have a generic "event emitter" Stimulus controller that can bubble-up events in the DOM based on form POSTs, for example. Though, I suppose the same thing could be done with a custom Turbo Stream action (since it's generic). Sorry, just thinking out loud. Cool stuff!