jose.omg.lol

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:

Play button Static: Demo of how the feature works Animated: Demo of how the feature works
Fig. 1: This is what interpolating an attribute looks like

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:

Play button Static: Demo of how the notification badge works Animated: Demo of how the notification badge works
Fig. 2: This is what interpolating an *empty* attribute looks like

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:

Demo of the attribute editing form
Fig. 3: Attribute edit form beside the plan, in 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:

  1. The existing controllers don't respond to Turbo Stream requests yet. Let's make them do so.
  2. The Turbo Stream response should contain an invisible element that will get appended to the DOM.
  3. The invisible element will trigger✨ a stimulus callback that will submit an HTTP request as a side-effect.
  4. 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!

Get notified about new posts