Here are two issues we ran into implementing a library of reusable UI components in Stimulus, and how we worked around them by rethinking our approach to outlets, targets and events.
For those new to the framework, Stimulus controllers are lightweight JS objects attached to DOM elements. They are descendants of Controller
, which is exported by @hotwired/stimulus
, and associated with elements by setting the data-controller
attribute to the controller’s identifier, e.g.:
<div id="counter" data-controller="counter" />
export default class CounterController extends Controller {
...
}
All else equal, Stimulus assumes a controller’s class is enough information to identify it on the page and interact with it. Outlets, Stimulus’ primary mechanism for controllers to access other controllers, are identified by their class. Consider the following example from the Stimulus documentation:
export default class ChatController extends Controller {
static outlets = [ "user-status" ]
connect () {
this.userStatusOutlets.forEach(status => ...)
}
}
The elements containing the controllers will be identified in the DOM via data-chat-user-status-outlet
attributes which can have any value you like, but once those elements have been identified, they will be expected to have a UserStatusController
— note that the controller class is encoded in the attribute name. From the documentation: “Stimulus will throw an exception if you try to declare an element as an outlet which doesn’t have a corresponding data-controller and identifier on it.”
Consider further the case of custom events, emitted by controllers with dispatch
. These are like click
and blur
, but user-defined, and they can carry structured data payloads. Custom events have prefixes that allow them to be namespaced, so you can tell the difference between, say, a confirm
event sent from a form footer versus one sent from a modal. By default, the prefix of a custom event is the name of the controller that emits it, slightly transformed: ModalController
will emit modal:confirm
, while FormFooterController
will emit form-footer:confirm
. In order to consume those events, you’d bind them in the DOM with a data-action
attribute:
<div ... data-action="form-footer:confirm@window->confetti#blast" ... />
This makes sense in the case of footers and modals, because there’s usually only one of those things on any given page. If there were more than one though, ConfettiController
wouldn’t be able to tell them apart.
Because there’s usually only one, the class name of a modal or a footer can identify both what the component is and what it means in the application. When components are intended to be reused, this is no longer the case, because the class becomes generic, like SelectController
. Suppose you have such a component that represents a custom select box. Two requirements:
Class names like SelectController
still identify what the component is, but no longer identify its meaning. Imagine a contact form with multiple select boxes. One is for (say) country, another for states and provinces, another is for the contact’s gender, and so on.
Having encountered this situation in the wild, I found it wasn’t very useful to address all of the select boxes in the form, because they had nothing in common past the UI element. That’s the state of things with outlets, though: Because outlets are identified by their class, to control a particular select box with an outlet, a controller would have to poll all of the select boxes on the page and then filter them by some other means. It also breaks encapsulation by forcing callers to know the concrete UI element of their target (a select box) rather than its meaning in the application (country, states and provinces, contact gender).
Suppose our form populates the states and provinces box dynamically when the user selects a country. This is handled by a higher level controller, say, ContactFormController
. In Stimulus, the primary difference between targets and outlets is that targets relate controllers to DOM elements, while outlets relate them to other controllers. Where outlets must be identified by their class, targets are identified by arbitrary names in DOM attributes, so we can simply flag the state-and-province select as a target like so:
<div data-controller="select" data-contact-form-target="statesAndProvinces" ... />
Now we know where on the page to send the update. The problem now is that the target is a DOM element, rather than the SelectController
. This is why outlets require their class to be known: Even if you have it narrowed down to a single element, Stimulus needs to know which controller loaded on that object you want to call.
What happens next probably depends on your components. In our case, we were wrapping React components with Stimulus controllers, so we wanted higher level controllers like ContactFormController
to be able to reinitialize those components with new props. The Stimulus controllers wrapping the React components all inherited from a common base class, ComponentController
, which set a listener for a custom update
event. Callers like ContactFormController
could then identify the component’s element as a target, and dispatch an update
event on it with the new props as a payload.
The other difference between outlets and targets is that target elements must fall under their controller’s element in the DOM hierarchy, while outlets can be anywhere on the page. If you use the approach above, the higher level controller must be defined on an element that encompasses all of the components that depend on it.
If this is a problem, components with no common roots can be coordinated with events, (see below), but a better first approach might be to put something like ContactFormController
near the top of the hierarchy, rather than on the form element itself.
The other problem is how you consume events emitted by these controllers. When the user makes a selection, the SelectController
on the country input will (let’s say) announce its new value with a custom event. How do you bind it?
<div data-controller="contact-form" data-action="select:change->contact-form#updateStatesAndProvinces">
This method will be called whenever a select box in the form is used. We could check some value in the payload once the method is called, but like polling all outlets of a given type to find the right one, that gives us the ick. We should be able to use data-action
more selectively than that. What we want is more like this:
data-action="country:select->contact-form#updateStatesAndProvinces"
Instead of using the prefix to identify the class that emitted the event, it becomes a sort of channel name that can be used to connect arbitrary controllers anywhere on the page. (Add @window
to the event name to listen for events emitted outside of the controller’s hierarchy.) We can then specify this value when invoking the component, which in our case was done by rendering a partial:
<!-- app/views/contacts/_form.html.erb -->
<%= render "components/select", channel: "country", props: ... %>
<%= render "components/select", channel: "states-and-provinces", props: ... %>
The partial passes the value into the component controller:
<!-- app/views/components/_select.html.erb -->
<div data-controller="select" data-select-channel-value=<%= channel %> ... %>
Then, when implementing your components, you can override the default prefix when you call dispatch
:
// in SelectController, on user selection
dispatch("select", prefix: channelValue, detail: { ... })
In keeping with its ”modest” design goals, Stimulus optimizes for clarity by identifying controllers by their class. In the majority of cases where we’re just adding a bit of dynamism to an otherwise complete page, this makes sense because you can just refer to a controller by what it provides. Reusability is a bit of a curveball because it breaks that assumption—what a bit of UI is no longer identifies what it does—but we don’t have to veer too far from the Stimulus happy path to accommodate it.