Refactored Telegram

It’s all just ones and zeros under the cover

Communication Among Stimulus Controllers: Part 2

This is part 2 on Communication Among Stimulus Controllers. In this post we will explore the patterns of communication from a parent controller to a child controllers, and from two controllers unrelated to each other.

You can find part 1 here.

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.