The Stimulus Trigger Pattern
[Turbo Streams] are strictly limited to DOM changes [...] if you need more than DOM change, connect a Stimulus controller.
– Hotwire introductory video @ 4:55
At Arrows, we build mutual action plans. These are, in short, a shared space for two companies to communicate with each other. One of my projects last year was building liquid tags for these plans. The goal was for users to interpolate arbitrary attributes from different sources onto a plan and give them values before or after interpolating.
Here's a GIF of me interpolating the plan coordinator's email onto the body of a to-do item:
The flexibility of interpolating an attribute before it has a value comes with a cost: we need to notify users if they accidentally interpolate a blank value before they share the plan with others.
We came up with a notification badge that will appear as soon as you interpolate an empty attribute and go away once you fill it in:
Great! But now there's another challenge. We wanted to give users a way to edit these attributes without leaving the plan page. Namely, in a form located within the sidebar:
So, what's the problem?
To summarize: in a single page, we're...
- Visualizing a plan (i.e.
PlansController#show
) - Interpolating values from other models (emphatically not from the plan)
- Editing said values
- Getting visual feedback in real-time whenever those values are empty
There are lots of things at play here. But the main thing to note is that references to empty values
exist within the scope of a Plan
, while the referenced values themselves exist within the scope of
a different model.
In other words, User
and Customer
already have established controllers that know how to update the
respective model's attributes. And none of those controllers are aware of the existence of a Plan
,
let alone the need to calculate how many empty references exist on one.
Do we replicate the business logic found in those controllers in a new, plan-aware controller? We could wrap the updating code (which, despite what I let on in this post, isn't trivial) in a PORO and use it in multiple places.
...or we could leverage Hotwire and avoid unnecessary abstractions.
The solution
In my mind, the fact that the form containing these attributes is inside a sidebar in the Plan
view
is coincidental. It does not merit the creation of a new controller (or multiple controllers, in this case).
Specifically, because this new controller would do exactly the same thing as the existing
one plus this one other thing I'd chalk up to an implementation detail.
So what do we do? We're going to reuse the existing controllers. And, as a bonus, we're going to dismantle the common notion that Turbo Streams are a WebSockets tool, as others have done before.
Here's the plan:
- The existing controllers don't respond to Turbo Stream requests yet. Let's make them do so.
- The Turbo Stream response should contain an invisible element that will get appended to the DOM.
- The invisible element will trigger✨ a stimulus callback that will submit an HTTP request as a side-effect.
- The HTTP request will go to a dedicated
Plans::EmptyReferences
controller, which will append the actual notification badge to the DOM.
Step 1: Sending and interpreting the Turbo Stream request
Whenever we interpolate an attribute or edit it in the sidebar form, we need to send the new value
in an HTTP request with an Accept: text/vnd.turbo-stream.html
header.
Turbo injects this format
when submitting a form, so we're all set there. The fields in our app happen to auto-save,
but yours don't have to.
Once we've done that, we need to make our controllers aware of Turbo Stream requests. Here's what one such controller might look like:
class CustomersController < ApplicationController
def update
@customer = Customer.find(params[:id])
respond_to do |format|
if @customer.update(customer_params)
format.turbo_stream
format.html { redirect_to @customer, notice: "Success!" }
else
format.turbo_stream { head :unprocessable_entity }
format.html do
flash.now[:alert] = "Whoops!"
render action: "edit"
end
end
end
end
end
Now, our controller will look for an update.turbo_stream.erb
view to render after it's finished updating.
Step 2: Responding to the Turbo Stream request
Let's create our update.turbo_stream.erb
view. This view will append an invisible element to a
predetermined place in your DOM. It's up to you where that place is. In this example, it's an element with
id="empty_references_triggers"
.
We're going to register this element as a Stimulus target which will trigger the <name>TargetConnected
callbacks, as long as we insert it as a child node of a stimulus-controlled element with
data-controller="fetch"
.
<%= turbo_stream.append :empty_references_triggers do %>
<template data-fetch-target="trigger"></template>
<% end %>
Is this tightly coupling two unrelated parts of the domain model? Sure. But it allows for very natural code reuse, so I don't mind it. Perfectly reasonable people might disagree with me here, though.
Notice how we can keep this in our Turbo Stream response even if our controller needs to start responding
to Turbo Stream requests for something else. If Turbo can't find a place to insert the
<template>
, it just won't do anything with it, and that'll be that. No harm done.
Step 3: Triggering the HTTP request side-effect
We'll use Request.JS to submit the actual HTTP request.
The library takes care of things like appending the X-CSRF-Token
header,
so we don't need to worry about that.
In our case, we want to fire the request whenever we connect the controller, as well as whenever Stimulus detects the connection of a new trigger:
import { Controller } from "@hotwired/stimulus"
import { get } from "@rails/request.js"
export default class extends Controller {
static targets = [ "trigger" ]
static values = { path: String }
connect() {
this.fetch()
}
triggerTargetConnected(_element) {
this.fetch()
}
// private
fetch() {
get(this.pathValue, { responseKind: "turbo-stream" })
}
}
Notice how we're passing the path to the controller as a value
. That allows for reusing this controller
in many places. It's one of the principles I learned from reading my friend
Matt Swanson's post on
writing better StimulusJS controllers.
You can take it one step further by passing in the path as a data attribute, which might look
somewhat like this:
triggerTargetConnected(element) {
const path = element.dataset["path"]
this.fetch(path)
}
// private
fetch(path) {
get(path, { responseKind: "turbo-stream" })
}
In our case, we need the context of which plan we're acting on. So we'll stick with providing the path
as a value in the Plan
view.
Step 4: Profit.
This last part is application-specific. In our case, the path we provided went to a controller action that knew how to return the number of empty references in a plan. Whatever you end up doing here will probably involve a mixture of appending, replacing, and removing elements from your DOM with Turbo Stream responses.
Conclusion
I hope walking through a real example helped materialize the ideas behind this blog post. I feel like giving context into how you might arrive at situations like this is just as important as telling you how you might solve them.
Notice how we didn't make extraordinarily novel use of Hotwire. We just used the tools that were already at our disposal in a way that might not be intuitive at first glance.
For anyone who hasn't experienced this pattern, I hope this added a device to your Hotwire toolbox you can reach for when the time is right. For anyone else, I hope you gained something by looking at this problem from the perspective of this domain model.
That's all, folks!