Using Nested Stimulus Controllers With Hotwire And Lucee CFML
The other day, on the Hotwire Dev Forum, I was having a discussion about communicating across Stimulus controllers. Most of my explorations so far have revolved around Turbo and progressively enhancing a ColdFusion application. As such, I didn't have much to offer in the way of advice. In order to help flesh out my mental model for Stimulus controllers, I wanted to put together a demo that explores a few different ways to communicate between a child controller and a parent controller in a Hotwire application.
View this code in my ColdFusion + Hotwire Demos project on GitHub.
In this ColdFusion demo, I'm going to render an unordered list (<ul>
). The list itself will have a Stimulus controller - the Parent controller; and, each list item will have a Stimulus controller - the Child controller. The CFML will look like this:
<ul data-controller="parent">
<li data-controller="child"> ... </li>
<li data-controller="child"> ... </li>
<li data-controller="child"> ... </li>
</ul>
In this case, we are going to be nesting scopes. There is the scope that the parent controller knows about; and, there's the scope that the child controller knows about. The good news is, our child scopes can reference the parent scope. Which means that we can trigger actions on the parent controller from within the HTML markup of the child scope.
So, our first approach to cross-controller communication is going to be to include a button that has a data-action
attribute that triggers a method directly on the parent controller:
<button
data-action="parent##logAction"
data-parent-child-id-param="#encodeForHtmlAttribute( item.id )#"
data-parent-child-data-param="#encodeForHtmlAttribute( serializeJson( item ) )#">
Trigger Parent Action
</button>
This data-action
attribute is going to invoke the .logAction(event)
method on the parent controller with the click
event on the button. Stimulus allows us to provide event metadata in the form of *-param
attributes:
data-{ controller }-{ name }-param
In this case, we're going to be providing childId
and childData
as event parameters, which will be made available in the event.params
data structure. The parent controller can then use this data however it pleases.
This approach is technically communicating across scopes; but, it isn't really involving two different controllers. If we wanted to pull the child controller into the communication chain, one way that we could do that would be dispatch an event from the child controller that the parent controller could then bind to.
To dispatch an event, let's include another button inside our list item that invokes an action on the given child controller, not the parent controller:
<button data-action="child##emitEvent">
Emit Child Event
</button>
This button will invoke the .emitEvent(event)
method on the child controller instance. In this case, we don't need to include any *-param
data attributes since we're routing the control flow through the child controller; and, the child controller should have direct access to any metadata that it wants to provide in the event.
We'll see this in more detail momentarily; but, for the moment, assume that this .emitEvent()
method is going to dispatch an event of type "hello"
. Stimulus will create a custom event behind the scenes that combines the given type prefixed with the name of the controller. So, our hello
event gets broadcast as child:hello
.
And, our parent controller can then bind to this child:hello
action with a data-action
attribute:
<ul
data-controller="parent"
data-action="child:hello->parent##logEvent">
....
</ul>
In this case, when the parent controller see the child:hello
event that's been emitted by one of the child controllers, it's going to invoke its own .logEvent(event)
method.
Bringing this all together, here's our ColdFusion demo page:
<cfscript>
items = [
{ id: 1, name: "Item One" },
{ id: 2, name: "Item Two" },
{ id: 3, name: "Item Three" }
];
</cfscript>
<cfmodule template="./tags/page.cfm">
<cfoutput>
<h2>
Welcome to My Site
</h2>
<!--- There is a PARENT CONTROLLER on the list. --->
<ul
data-controller="parent"
data-action="child:hello->parent##logEvent">
<cfloop item="item" array="#items#">
<!--- There is a CHILD CONTROLLER on each list item. --->
<li
data-controller="child"
data-child-id-value="#encodeForHtmlAttribute( item.id )#">
#encodeForHtml( item.name )#
<!---
Even though we are inside the CHILD controller scope, we are still
technically in the SCOPE of the PARENT controller as well. As
such, we can reach outside of the child scope and trigger actions
on the parent controller. In this case, we're going to include
child-related data as PARAMS on the triggered event.
--->
<button
data-action="parent##logAction"
data-parent-child-id-param="#encodeForHtmlAttribute( item.id )#"
data-parent-child-data-param="#encodeForHtmlAttribute( serializeJson( item ) )#">
Trigger Parent Action
</button>
<!---
We can also emit / trigger / broadcast / dispatch events from the
CHILD controller up the DOM tree (like any non-custom event). The
PARENT controller can then listen for these events in its own
action bindings.
--->
<button data-action="child##emitEvent">
Emit Child Event
</button>
</li>
</cfloop>
</ul>
</cfoutput>
</cfmodule>
Notice that I'm including a data-child-id-value
on each list item. This will expose the item id
to the child controller (as .idValue
). We can then use that idValue
as metadata in our dispatched event.
Here are the Stimulus controllers that glue all of this together:
// Import core modules.
import { Application } from "@hotwired/stimulus";
import { Controller } from "@hotwired/stimulus";
import * as Turbo from "@hotwired/turbo";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
class ParentController extends Controller {
/**
* I log the event when the button in the CHILD HTML is used to invoke an action on
* this PARENT controller instance.
*/
logAction( event ) {
console.group( "Parent Action [%s]", event.type );
console.log( "Child ID:", event.params.childId );
console.log( "Child Data:", event.params.childData );
console.groupEnd();
}
/**
* I log the event that the CHILD controller emits up the DOM tree.
*/
logEvent( event ) {
console.group( "Parent Event [%s]", event.type );
console.log( "Child ID:", event.detail.id );
console.groupEnd();
}
}
class ChildController extends Controller {
static values = {
id: Number
};
/**
* I emit a demo event up the DOM tree where any higher-up controller can bind to it.
*/
emitEvent() {
this.dispatch(
"hello",
{
detail: {
id: this.idValue
}
}
);
}
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
window.Stimulus = Application.start();
// When not using the Ruby On Rails asset pipeline / build system, Stimulus doesn't know
// how to map controller classes to data-controller attributes. As such, we have to
// explicitly register the Controllers on Stimulus startup.
Stimulus.register( "parent", ParentController );
Stimulus.register( "child", ChildController );
As you can see from this JavaScript, neither controller - parent or child - knows about the other, all linkage is being powered by the actual DOM (Document Object Model) tree via events and actions. This is "The Hotwire Way", in so much as the DOM is the source of truth, the state of the application, and the chain of communication. The controllers are there simply to enhance the DOM tree.
If we load up this ColdFusion application and click on the buttons, we can see how the parent controller reacts to events and actions triggered from within the child controller scope:
I love the fact that the constraints of the Hotwire / Stimulus framework force us to keep things decoupled. In this case, our nested controllers are able to communicate (up); but, in a way that allows them to change largely independently of each other.
Want to use code from this post? Check out the license.
Reader Comments
Here's a related post on accessing a child controller from the parent controller:
www.bennadel.com/blog/4449-accessing-stimulus-controllers-from-a-given-dom-element-in-hotwire.htm
This uses Hotwire's ability to get a controller instance from the DOM (using the identifier that the bound the controller to the DOM in the first place).
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →