Communication Among Stimulus Controllers
From the time I’ve started playing around with Stimulus, I’ve been thinking about the best way that controllers can communicate with each other.
The nice thing about Stimulus is that the state of the controller is representable from within the DOM itself, meaning that if a property of a controller needs to change, it can be done so by simply modifying the attribute value of the element the controller is bound to (see here for how that works).
This, however, doesn’t extend nicely to instances when you only need to notify the controller of an event or send a message that is more one shot: “I did a thing”, or “please do that thing”. It’s not always possible to translate these interactions to one that is based on modifying attribute values, and most attempts to do so always end up as a bit of a code smell.
After a bit of experimentation, I think I’ve found a method which works for me.
Existing Solutions
Stimulus does not have a way of doing this using their API, at least at the time this post was written. I’ve found some examples of how to achieve this using the private API which, although they may work, I found slightly ugly. In general, I try to avoid dealing with the unexposed inners of a library, even if it’s implicitly available to me1. Doing so usually results in constant maintenance of the integration, as the original library authors assume that they are free to change or refactor the internals as they see fit. It then falls on you to keep up, and fix any breakages as they occur.
One could argue that going to these extremes indicate that support for this form of communication is missing in the library. This might be true, but the fact remains that it’s not available to me now. So an alternative will have to be considered.
The Approach
For a while I’ve been wondering this could be done at all. But after a bit of playing around, I’ve settled on a solution that uses Custom Events — the ability to create, and receive, events that are completely user definable — to achieve this.
Custom events have have some nice properties that work well with Stimulus:
- It’s already available, and well supported by modern browser.
- It works with the Stimulus
data-action
attribute. - It doesn’t assume any internal workings of Stimulus itself.
The general approach is as follows: a controller that wants to send a message to another controller
creates a custom event, and sends it using the dispatchEvent()
method on a DOM element:
element.dispatchEvent(new CustomEvent("eventName", {
detail: {
// optional details
},
}));
A custom event is made by calling the CustomEvent
constructor and providing it with an event name. Data can be associated with the event by putting it inside the detail
object.
Controllers interested in receiving the event can use data-action
to register a handler, just like any other browser event. The optional details can be retrieved through the detail
attribute on the event object passed into the handler:
<div data-controller="listener" data-action="eventName->listener#eventHandler">...</div>
export default class extends Controller {
eventHandler(ev) {
let details = ev.detail;
// Handle the event
}
}
That’s pretty much all there is to it. The only thing that changes is which element actually dispatches the event, along with some specifics on how the event is created. These relate closely on the relationship of the various controllers, which are explored in the following sections.
Pattern 1: From Child To Parent Controllers
This pattern applies for events that are sent from the child to the parent. There are some cases where this can come in useful, such as when a child controller wants to propagate the value of input fields to the parent controller bound to a form, potentially useful when determining whether it’s safe to submit it.
First, it’s probably important to highlight what this is not. It’s not simply referring to the parent controller in an element nested within a child controller:
<div data-controller="parent">
<div data-controller="child">
<!-- The child is not sending the event. The button is contacting the parent directly -->
<button data-action="click->parent#doSomething">Do Something</button>
</div>
</div>
The child controller is available but the actual handler in the example above is actually handled by the parent. As such, the child is not notifying the parent of the event: the button element is.
Instead, this pattern is for cases whereby the child controller itself sends the event, and it works when the event is configured to “bubble up”.
Event bubbling is a mechanism of event propagation whereby the browser will pass the event up through the DOM, running each of the event handlers that are capable of handling the event. This means that when an event is dispatching from the element bound to the child controller, and that event is configured to bubble up, it will pass through he parent controller on it’s way up to the root of the DOM. The parent just needs to listen for it by setting up a data-action
handler.
Here’s an example:
<div data-controller="parent" data-action="messageFromChild->parent#handleMessage">
<span data-cp-parent-target="message"></span>
<div data-controller="child">
<button data-action="child#sendMessageToParent">Send Message</button>
</div>
</div>
The child:
export default class extends Controller {
sendMessageToParent() {
this.element.dispatchEvent(new CustomEvent("messageFromChild", {
detail: {
message: "Hello from child"
},
bubbles: true // This needs to be explicitly set to enable event bubbling
}));
}
}
The parent:
export default class extends Controller {
static targets = ["message"];
handleMessage(ev) {
console.log("Received event");
let message = ev.detail.message;
this.messageTarget.textContent = `Message from child: ${message}`;
}
}
In this example, clicking the button will cause the child controller to dispatch the messageFromChild
event. That event will bubble up the DOM and be caught by the handleMessage
event handler configured on the parent. The parent is free to react to the event and display the message sent from the child.
The parent handler can also include a call to the stopPropogation() method on ev
to prevent the event bubbling further up the DOM if necessary.
From Parent To Child Controllers
Browser events that bubble up will not work in cases when a message needs to be sent from the parent to a child.
First, it’s worth considering whether events are the best way to pass information from the parent to the child. Even though StimulusJS is not a framework like ReactJS or Vue, I like to follow the approach to data exchange that these frameworks recommend, which is to:
- Pass information from parent to child through the use of properties (or in the case of Stimulus, through values).
- Pass information from the child to the parent through the use of events
The fact that events coming from the child are easier to handle than events sent to the child provides an incentive to follow this pattern. It’s usually better to go with the grain when it comes to these decisions rather than attempt to fight it. But I’m fully cognisant that this is not possible in all circumstances, and sometimes an alternative approach is necessary.
With that out of the way, here’s an approach for a parent controller to notify a child within its scope. Events are used here as well. The only difference is that instead of relying on implicit event bubbling, we can send an event directly to the controller.
Consider the following HTML, in which we want to display a message within the <span>
when the button is pressed:
<div data-controller="parent">
<button data-action="parent#sendMessageToChild">
Send Message
</button>
<div data-controller="child" data-parent-target="children" data-action="messageFromParent->child#handleMessage">
<span data-child-target="message"></span>
</div>
</div>
Thanks to the data-target
attribute, the parent controller has a direct reference to the child element. We can therefore invoke dispatchEvent
directly on the child element:
import { Controller } from "stimulus"
export default class extends Controller {
static targets = ["children"];
sendMessageToChild() {
this.childrenTarget.dispatchEvent(new CustomEvent("messageFromParent", {
detail: {
message: "Hello from parent"
}
}));
}
}
Doing so will fire the event handler declared within data-action
:
import { Controller } from "stimulus"
export default class extends Controller {
static targets = ["message"];
handleMessage(ev) {
let message = ev.detail.message;
this.messageTarget.textContent = `Message from parent: ${message}`;
}
}
It’s also possible to do this to multiple child events bound to the same data-target
annotation:
// Parent
sendMessageToChild() {
for (let target of this.childrenTargets) {
target.dispatchEvent(new CustomEvent("messageFromParent", {
detail: {
message: "Hello from parent"
}
}));
}
}
For Controllers That Are Distinct
The final pattern is one involving controllers that are distinct. These are controllers that do not have a parent-child relationship in any way, making it difficult to get a reference to the element or implicitly pass events between the two. It’s still possible for the two controllers to communicate with each other, but it will require some mediation from the top-level window
object.
The window
object fires events that relate to the browser window itself, and Stimulus allows controllers to subscribe to these events through the use of the @window
modifier in the data-action attribute value. For example, setting up a handler which will be invoked when the window is resized can be done in the following way:
<div data-controller="receiver1" data-action="resize@window->receiver#handleResizeEvent">
This also works for custom events, meaning that we can use the window
object as a form of message broker. If we want an event to reach components that we cannot reference directly, we can simply “publish” a custom browser event to the window
object:
window.dispatchEvent(new CustomEvent("messageFromSender"));
And all interested components can “subscribe” to that event by attaching a handler to the window using the data-action
attribute.
<div data-controller="messageSender">
<button>Send Message</button>
</div>
<div data-controller="receiver1" data-action="messageFromSender@window->receiver1#handleMessage">
<div data-receiver1-target="message">This is the message</div>
</div>
<div data-controller="receiver2" data-action="messageFromSender@window->receiver2#handleMessage">
<div data-receiver1-target="message">This will also handle message</div>
</div>
The nice thing about this pattern is that is does not assume anything between the two controllers that are communicating. The sender knows nothing about which controllers that have subscribed to the events that it’s posting to window
. If there are no subscribers, then the event will simply be left unhandled. Likewise, if a controller sets up a handler for a window event that will never be published, it wouldn’t need to handle the lack of any publishers as a special case. The handler will simply never be called.
This leaves a nice, flexible architecture in which controllers are completely independent from each other, yet can still receive messages from any of them. This frees up the controller elements so that they can be rearranged within the HTML as deemed necessary — even be nested within each other, forming new parent-child relationships — without the need to change any of the controller’s code.
-
This is easy in a language like JavaScript with relatively weak boundaries between modules. ↩︎