Skip to main content
Ben Nadel at FirstMark Tech Summit (New York, NY) with: Atticus White
Ben Nadel at FirstMark Tech Summit (New York, NY) with: Atticus White ( @atticoos )

Using Nested Stimulus Controllers With Hotwire And Lucee CFML

By on

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:

Actions and events are logged from the parent Stimulus controller.

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

Post A Comment — I'd Love To Hear From You!

Post a Comment

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel