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>
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”.
<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.
// 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.
Top comments (1)
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 formPOST
s, 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!