A decoupled approach to relaying events between Stimulus controllers
Part of the allure of Stimulus is that you can attach rich, dynamic behavior to the DOM without building out a long-lived stateful application in the browser.
The pitch is that each controller is an island unto itself, with each adding a particular kind of behavior (e.g. a controller for copying to clipboard, another for displaying upload status, another for drag-and-drop reordering), configured entirely via data attributes. This works really well when user behavior directly initiates all of the behaviors a Stimulus controller needs to implement.
This works markedly less well when a controller's behavior needs to be triggered by another controller.
The Problem
Each major version of Stimulus has improved this story, adding features like Outlets and a convenience method for dispatching namespaced events. At this point, Stimulus's built-in API covers the majority of ways you'd need to trigger controller behavior indirectly, but they sometimes introduce pressure to unnecessarily couple controllers that wouldn't otherwise need to know anything about each other.
When it comes to communicating via events, I recently described solving for the "race up the DOM" that can occur due to the fact that the event sender needs to either be a descendant of the recipient, hold a reference to an element that descends from the recipient, or else be installed on the same node as the recipient.
This aspect of organizing one's JavaScript behavior declaratively in the DOM can be awkward, however, because moving a data-controller
upstream in
order to allow coordination between controllers will often require the developer
to write custom code to replace the sort of bookkeeping Stimulus normally does
for you:
- If the controller's element was previously entirely-surrounded by a replaceable
Turbo frame or stream, then its
connect()
/disconnect()
methods would allow for easy setup and teardown. But once the controller moves higher up the DOM such that only its descendants are being replaced, those same setup/teardown tasks might require an additional MutationObserver to work - When a page contains multiple instances of a controller—each containing a single target, action, and/or value for a given operation—it lends itself to simple and straightforward code that resembles what you'd see in a tutorial. However, if the controller's element is moved upstream such that a single controller instance has to handle an array of said targets, then that controller will also require additional logic to match each action up to the correct element and/or manually track values in arrays and objects as opposed to framework-managed primitives
- Whenever a controller needs to move so far up the DOM that it needs to break out of whatever partial or component it should logically reside in, the developer might find themselves resorting to confusing, hard-to-cache solutions like this nonsense I wrote about last month in order to keep the code sufficiently encapsulated and organized
These pain points are by no means inevitable, and like I mentioned, Outlets are one way to expand beyond hierarchical event dispatch by instead leaning on arbitrary CSS selectors. That, of course, represents more explicit coupling between controllers, but coupling all the same. Either approach is a bummer if you believe the value proposition of Stimulus is the degree to which its controllers can stand alone, without concern for how the controllers around it might change.
(Seriously, Stimulus is the first time in my >20 years of writing browser apps with JavaScript that many of the features I implement are so generically useful that I could turn around and publish many of them as open source with very little additional work. It's a very cool feeling!)
The Root Cause
Often when an application needs to coordinate two things that perform unrelated tasks, they wind up being tangled up in this way as a sort of single-responsibility principle violation, wherein changes to either often necessitate changes to both:
- Controller A bakes bread, and also also needs to trigger an event in a way that Controller B can receive
- Controller B slices bread, and also needs to position itself to receive Controller A's notification about fresh bread
If I need to plan around whether A will be installed onto an ancestor of B's node, or if I ever catch myself thinking, "it's a problem that these controllers belong to far-removed cousins on opposite ends of the page," that's a clear indication controllers A and B aren't truly independent. It's an example of an anti-pattern I like to call "tightly decoupled". There may not be any explicit reference linking the source code of the two controllers, but they're nevertheless implicitly linked. In this case, things will break if they're not arranged in the DOM just so.
The Solution
Instead, the solution to this category of design problems is often some third thing that is responsible only for the coordination of other things:
- Controller A bakes bread and emits an event when it's done
- Controller B slices bread in response to fresh bread
- Controller C relays events from one controller to others elsewhere
(To be clear: 80% of the time, no third thing is even necessary—the relationship between any controllers A and B is usually natural and obvious and communicating via events Just Works™ without thinking about any of this. This only becomes an issue when the DOM isn't arranged in a way that easily facilitates event-based communication.)
So here's a pattern I recently started using in order to avoid moving
data-controller
attributes further up the DOM to solve this problem. I wrote a
little controller called RelayController
that simply allows a sending
controller to rebroadcast its events elsewhere and a receiving controller to
subscribe to those events, regardless of where it lives in the DOM. In order to
avoid the chaos of a truly global event bus (requiring listeners to filter out a
bunch of events on targets they may not care about), the relay controller can be
scoped to the nearest common ancestor of the relevant controllers.
Here's a contrived example. Suppose you have these two controllers residing as cousins on the DOM:
<div data-controller="list-appender">
<form onsubmit="return false;">
<label for="name">Name</label>
<input type="text" id="name" data-list-appender-target="nameInput" data-action="keydown.enter->list-appender#append">
<button type="button" data-action="list-appender#append">Add</button>
</form>
<ul data-list-appender-target="list">
</ul>
</div>
<!-- Imagine a lot of DOM nodes between the above and the below -->
<div data-controller="commentator"
data-action="list-appender:listWasAppended->commentator#comment">
</div>
Above, you may be able to piece together that the ListAppenderController
has
an append
action that's fired whenever someone hits enter or the "Add" button,
which you might guess appends an <li>
to its list
target.
Below, the CommentatorController
's action suggests it wants to make a comment after anything is appended to the list.
However, this won't work on its own, because neither controller is bound to an
ancestor of the other, so the
list-appender:listWasAppended
event will bubble right up the DOM and never
visit the CommentatorController
's bound element.
The naive solution—which is often just fine but at other times is not very
fine at all (it depends), would involve moving either or both of these
controllers up to a common ancestor of both branches of the DOM. In practice,
this could mean: moving up the commentator
and listen for the
listWasAppended
event from there, moving up the list-appender
and add adding
a target on the commentator (to which the the event could be dispatched), or
moving up both controllers to the same parent element.
Like I said, sometimes that's the right and proper thing to do, but just as often it can lead to one of the coupling issues I discussed at the outset.
So, here's an updated version specifying a new, third controller responsible only for communication among controllers who find themselves distant relatives:
<div data-controller="relay">
<div data-controller="list-appender"
data-action="list-appender:listWasAppended->relay#forward">
<form onsubmit="return false;">
<label for="name">Name</label>
<input type="text" id="name" data-list-appender-target="nameInput" data-action="keydown.enter->list-appender#append">
<button type="button" data-action="list-appender#append">Add</button>
</form>
<ul data-list-appender-target="list">
</ul>
</div>
<div data-controller="commentator"
data-action="list-appender:listWasAppended->commentator#comment"
data-relay-events="list-appender:listWasAppended">
</div>
</div>
This adds just three things:
- A
data-controller=relay
to a shared ancestor of the first two controllers - An action on the list appender's element mapping from the
list-appender:listWasAppended
event to therelay#forward
action - A data attribute named
data-relay-events
that opts into receiving the"list-appender:listWasAppended"
event
The above controller could be implemented like this:
import { Controller } from '@hotwired/stimulus'
export default class RelayController extends Controller {
forward (e) {
const subscribers = this.element.
querySelectorAll(`[data-relay-events*='${e.type}']`)
subscribers.forEach(el => {
el.dispatchEvent(new CustomEvent(e.type, {
detail: e.detail,
params: e.params
}))
})
}
}
Despite having only a single method, it packs a punch:
- Searches for any elements underneath that instance of
RelayController
that have contain the given event type in adata-relay-events
attribute - Because we're using the
*=
operator, we are implementing a lazy (and potentially bug-prone) way of allowing that single attribute to specify multiple space-delimited events - Dispatches a new custom event of the same type to every such subscriber (I
only needed
detail
andparams
on the object, but YMMV)
If you're looking for more, here's a working sample repo that uses it.
Wait, why not make this a package?
Earlier, I mentioned that working with Stimulus off-and-on for a few years has made me appreciate how many of the JavaScript controller I write can truly stand alone as being generally useful, and yet (to the best of my recollection) I've never bothered to open source any of them.
Even in this post, I felt compelled to share this whole story with you, despite
the fact it would have taken half as much time to push stimulus-relay
out as a
new package, with a README showing people searching on Google how to use it so
they didn't have to think too hard about the underlying pattern.
So why not open source it?
Because it's like six lines long, man. In fact, every time I've pulled in a Stimulus controller as a third-party dependency, I've eventually gotten curious and realized that it was also like six lines long. The overhead of tracking a one-off library that shares a hard dependency with your app's primary JavaScript framework is way more risk than reward. Just copy paste this shit, make it your own however you need to, and move on.
And that's perhaps the best endorsement of Stimulus I can give: the reason you don't hear more people talking about it or marketing open source packages of Stimulus controllers is because they tend to be so small as to be trivial to implement yourself. The hard part is always the concept behind the underlying need—once you've clarified that, the code usually writes itself. 🪁