Stimulus 101: Building a Modal

Feature image: Stimulus 101: Building a Modal

Web developers have been adding interactivity to our web sites since JavaScript was introduced in 1995. For much of the last 20+ years, jQuery was the tool to use to add that interactivity. jQuery is simple, and provides a standard API to directly manipulate DOM elements in any browser. Then, in 2013, tools like React and Vue.js ushered in the era of the "virtual DOM," a representation of the real DOM built with languages that (often) look like HTML but which, in actuality, are JavaScript. Modifying the real DOM became blasphemous, something to be done as a last resort.

So when Basecamp announced Stimulus, self-described as "a modest JavaScript framework for the HTML you already have", the concept was somehow radical and familiar at the same time. Stimulus doesn't just allow you to modify the real DOM, it embraces the concept entirely.

In this post we'll talk about Stimulus, how it differs from other modern JavaScript frameworks, and how to use it to build a real on-page interaction--with the DOM you already have.

What's So Wrong with the Virtual DOM?

While the concept of the virtual DOM is undoubtedly clever, it has left those of us building applications that render HTML directly from the server with tools like Laravel in a bit of a bind. If we want to add even a little bit of interaction to our application, we must bend its structure to the will of our virtual DOM framework.

We're often left with two options: turn our server-side application into a JSON API whose sole purpose is to be consumed by a single page application (SPA), or intersperse virtual DOM components within our traditional HTML code. What we've traded often doesn't feel fair compared to what we got.

Stimulus, unlike React and Vue.js, has no notion of a virtual DOM. Instead, it creates a bridge between the real server-rendered DOM and JavaScript objects. Three core concepts are utilized to do so: controllers, targets, and actions.

  • Controllers are JavaScript classes that each map directly to an element in the DOM. Controllers give you control over all the children inside their matched element.
  • Targets are identifiers applied to DOM elements inside of controllers. Targets allow you to reference these child elements by name within your controllers.
  • Actions are methods on your controllers that will be fired in response to certain events. For instance, an action might be fired when a user clicks on a DOM element.

Don't worry if you didn't follow that completely; we're going to build something real that will help make these concepts much clearer.

What We're Building

In this walkthrough, we're going to rebuild Bootstrap 4's modal component using Stimulus. I recommend that you take a brief moment to read through Boostrap's documentation of the modal component before moving forward. Don't be fooled by the simplistic nature of this example--even with this simple component, we'll still have an opportunity to take a look at each of Stimulus' core concepts.

Getting Started

We can start by setting up a local installation of Stimulus. In addition to a fairly comprehensive installation guide, Stimulus offers a stimulus-starter repo for quickly getting up and running with a blank slate.

Although I recommend reading through the installation guide later to gain a general understanding of how it works, for this example we'll use stimulus-starter to keep things moving.

Let's copy the installation commands to our terminal directly from the README:

$ git clone https://github.com/stimulusjs/stimulus-starter.git
$ cd stimulus-starter
$ yarn install
$ yarn start

Wondering how to install Stimulus within Laravel? Don't worry, I wouldn't leave you hanging. Check out this gist for a Laravel-specific install guide.

If all went well, you should be able to visit http://localhost:9000/ in your preferred browser and see...nothing! I love a blank white screen as much as the next guy, but it won't quite cut it for our purposes. Since we know we're going to be using a Bootstrap component, let's take a moment to import the Bootstrap CSS into our page. Open up public/index.html and replace this line:

<link rel="stylesheet" href="main.css">

with a link to Bootstrap:

<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">

Next, since our page is still empty, let's create a basic wrapper <div> and put it in between our <body> tags. This wrapper will contain two things: a <button> to launch the modal, and the modal itself.

// public/index.html
<body>
<div id="app">
<button>Launch Demo</button>
 
<div class="modal">
<div class="modal-dialog">
<div class="modal-content">
This is a modal
</div>
</div>
</div>
</div>
</body>

Note: For the sake of keeping our example code short, I have stripped down Bootstrap's modal markup to its purest form. You can see the full markup in their documentation. Feel free to use it if you prefer.

If you refresh your page you should now see a single button that says "Launch Demo." Our goal here is to make it so that when a user clicks on that button, the modal appears. That means somehow we need to give Stimulus control over what happens when the button is clicked. You may remember earlier how we talked about "controllers" and how they map to DOM elements and give you control over the children inside of them. Well, that sounds like exactly what we need right now, so let's create our first controller.

Creating Our First Controller

Since we're building a demo, I think it makes sense to call our first controller, you guessed it, "demo." Create a new file inside of your src/controllers directory called demo-controller.js. Then, paste the following code into that file:

// src/controllers/demo-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
}

This is standard Stimulus boilerplate for a new class. It just imports a base Controller class from Stimulus and then creates a new class that extends that base class.

So, our demo controller exists now, but our HTML hasn't gotten the memo. Let's connect the controller to our HTML by adding a simple data-controller attribute to our top-level container <div>:

// public/index.html
<body>
<div id="app" data-controller="demo">
<button>Launch Demo</button>
 
<div class="modal">
<div class="modal-dialog">
<div class="modal-content">
This is a modal
</div>
</div>
</div>
</div>
</body>

Congratulations, you've just created a magical connection between Stimulus and the DOM! Refresh the page and be shocked and amazed at what you see! What's that? Nothing has changed? We don't have any proof that this connection actually exists?

Is This Thing On?

It's true that when you connect a Stimulus controller to your HTML code, there's no immediate reaction that confirms this connection exists. Sure, we could just trust Stimulus, but I live by the motto of "trust, but verify."

When a Stimulus controller connects to your DOM for the first time, it automatically fires a method called initialize. We can leverage this method to confirm our connection. Let's add an initialize method to our demo controller that contains a console.log:

// src/controllers/demo-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
initialize() {
console.log("Hello World");
}
}

initialize is just one of three lifecycle callbacks Stimulus offers (in addition to connect and disconnect). You can read more about the others in their documentation.

Once you save the file and refresh your page, you should see Hello World printed in your console! This is great; we've confirmed the connection exists... but how do we know it's connected to the right element?

In our case, it should be connected to the top level container <div>, but as of right now we really have no way of knowing that for sure. Thankfully, Stimulus controllers come built-in with a this.element property which gives us direct access to the element our controller is connected to.

Let's update our console.log to output the element itself:

// src/controllers/demo-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
initialize() {
console.log(this.element);
}
}

Now if you refresh the page, your console should look something like this:

Screenshot of console with log showing modal element

Fantastic; we've confirmed our connection.

Target Practice

We're making good progress here, but remember, our goal is to open the modal when a user clicks on the "Launch Demo" button. The problem is, right now our controller has no idea the modal exists! Sure, the modal is a child element of the controller, but how do we access it? It sounds like we want our controller to be able to target the modal. At the beginning of this post, we described targets as "identifiers that are applied to DOM elements inside of controllers that allow you to reference those elements by name." In this situation, our modal is the element that we want our demo controller to be able to target by name, so let's add a "modal" target to our demo controller like so:

// src/controllers/demo-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
static targets = ["modal"];
 
initialize() {
console.log(this.element);
}
}

As you can see above, targets are added to controllers via the static targets property. And just like with controllers, we still need to connect the target to our HTML. This time we'll add a data-target attribute to the element we want to target (in this case, our modal):

Note: The static prefix on the targets property may look unfamiliar to you. Static properties are not included by default in ES6, but the stimulus-starter generator we used makes them possible by including the @babel/plugin-proposal-class-properties. Static properties are properties that are called on the class itself, rather than on instances of the class. It is not important to fully understand them for the purposes of this demo.

// public/index.html
<body>
<div id="app" data-controller="demo">
<button>Launch Demo</button>
 
<div class="modal" data-target="demo.modal">
<div class="modal-dialog">
<div class="modal-content">
This is a modal
</div>
</div>
</div>
</div>
</body>

You may have noticed that we named our target demo.modal instead of just modal. This is because targets must be prefixed with the name of the controller they belong to, separated by a dot. You may be thinking: "Shouldn't the target know which controller it belongs to? It is nested inside that controller, after all." I'll explain this in detail later, but the short answer is: elements can be attached to more than one controller.

Now that our target is connected to our HTML, let's test the connection just like we did with our controller. We can reference a target anywhere in a controller with the following convention: this.[name]Target. So in this case, we can reference it by this.modalTarget. Let's update our initialize method to console.log our modal target:

// src/controllers/demo-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
static targets = ["modal"];
 
initialize() {
console.log(this.modalTarget);
}
}

If we return to our page and click the "Launch Demo" button again, instead of seeing our top-level <div> output in the console, we should see our modal <div>. We have successfully set up our first Stimulus target.

Less Talk, More Action

So far, we've been exclusively using the initialize method of our controller. The problem is, this method runs just once when the controller connects to the DOM for the first time. If we're going to open the modal when a user clicks the "Launch Demo" button, what we really need is a method that runs on demand.

This is where actions come in. What are actions? In classic Stimulus fashion, actions are nothing more than simple methods on a controller. We want to do something when a user clicks the "Launch Demo" button, so let's create an action called launchDemo:

// src/controllers/demo-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
static targets = ["modal"];
 
launchDemo(event) {
console.log(event);
}
}

As you can see, our launchDemo action method accepts an event parameter (as do all Stimulus actions by default), and then it console.logs it out.

Although we've created our action, our HTML still has no idea when to fire it. Just like with controllers and targets, we'll need to connect this action to our HTML. We can do that by adding a data-action property to our <button>:

// public/index.html
<body>
<div id="app" data-controller="demo">
<button data-action="click->demo#launchDemo">Launch Demo</button>
 
<div class="modal" data-target="demo.modal">
<div class="modal-dialog">
<div class="modal-content">
This is a modal
</div>
</div>
</div>
</div>
</body>

Let's take a look at each part of that action and break down what it does:

  • First, we have click->, which tells Stimulus what DOM event to listen for. Other events are outlined in the documentation.
  • Next, we have demo#. This is the name of the controller that contains the action method. Just like targets, actions must be prefixed with their parent controller's name, this time followed by a # character.
  • Finally, we have launchDemo, which is the name of the method we made up for the action inside our demo controller.

All together, Stimulus calls this string an "action descriptor". Let's refresh our page now and click the "Launch Demo" button a few more times. If all went well, the console should be filled with events:

Screenshot of console with log showing three events

We've now covered the three major concepts in Stimulus: controllers, targets, and actions. But our last step presents us with an interesting problem: We have a modal target that references our modal, and an action that runs whenever we click the "Launch Demo" button, but how do we tell our modal target that we want our modal to open? If anything, "open" almost sounds like an action that we want our modal target to do. But targets don't have actions, they're just named references to DOM elements. Could it be that our modal target also needs to be...a controller?

Mix and Match

What we just discovered is one of the big concepts in Stimulus that I didn't quite grasp at first. Elements don't have to be just controllers or just targets or just fire actions. In fact, it's very common for an element to be a target and a controller, or be a target and fire an action. In our demo app, the modal is a target of the demo controller, but it looks like it needs to be a controller itself too.

Let's get started by creating a modal-controller.js file inside of our src/controllers directory. We'll once again paste in the standard controller boilerplate with an added initialize method to confirm our connection:

// src/controllers/modal-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
initialize() {
console.log("Modal controller connected!");
}
}

Now that our controller is created, let's hook it up to our modal <div>:

// public/index.html
<body>
<div id="app" data-controller="demo">
<button data-action="click->demo#launchDemo">Launch Demo</button>
 
<div class="modal" data-controller="modal" data-target="demo.modal">
<div class="modal-dialog">
<div class="modal-content">
This is a modal
</div>
</div>
</div>
</div>
</body>

As you can see, we've added the data-controller="modal" attribute to our modal in addition to the data-target attribute. Our modal is now a target of the demo controller, and a controller itself. We can confirm as much by refreshing the page, and seeing our "Modal controller connected!" log in the console.

As usual, the initialize method is helpful for confirming a connection, but it's a bit too limited for our needs. Moving forward, we need to be able to open the modal whenever we want. Or in other words, "open" is an action we want to perform on a modal. So I think it makes sense to add an open action to our modal controller:

// src/controllers/modal-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
open() {
console.log("The modal has been opened!");
}
}

Things are coming together nicely, but something feels a bit... disconnected. We have our demo controller, which has a launchDemo action. Inside that action, we have access to our modal target. Additionally, we now have a modal controller, which contains our open action. How the heck do we call the open action from our target? If you're like me, your gut might be to try updating your launchDemo action to look something like this:

// src/controllers/demo-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
static targets = ["modal"];
 
launchDemo() {
this.modalTarget.open();
}
}

But if you try and run that, you'll just get a big fat error in your console. This is because our modal target is just a reference to a DOM element, it's not an instance of the modal controller. It has no idea that it's a controller too!

So what we really need is a way to access the modal controller from our modal target. Thankfully, Stimulus offers a method to do just that called getControllerForElementAndIdentifier.

getControllerForElementAndIdentifier

getControllerForElementAndIdentifier is a very powerful method built into Stimulus that allows you to pass in an element (often a target) and an identifier (often a controller name) and returns the controller instance as an object. Let's add this to our launchDemo action to access the modal controller from our modal target:

// src/controllers/demo-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
static targets = ["modal"];
 
launchDemo() {
let modalController = this.application.getControllerForElementAndIdentifier(
this.modalTarget,
"modal"
);
console.log(modalController);
}
}

We haven't discussed the this.application property on Stimulus controllers before. As you may expect, there's a global 'application' that represents Stimulus and this property returns it. Every controller has access to it.

We've passed getControllerForElementAndIdentifier two parameters: our modal target element, and the string "modal" which represents the name of the controller we want. If you refresh your page and click on the "Launch Demo" button a few times, you'll see your console is now filled with modal controller instances:

Screenshot of console with log showing modal controller instances

That means that from inside of our launchDemo action we can now call the open action on our modal controller, like so:

// src/controllers/demo-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
static targets = ["modal"];
 
launchDemo() {
let modalController = this.application.getControllerForElementAndIdentifier(
this.modalTarget,
"modal"
);
modalController.open();
}
}

Refresh your page again, click the button a few more times, and you should now see "The modal has been opened!" scattered about. Congratulations! You've now called a controller action from another controller action.

Opening the Modal (for real)

We've managed to tell our modal that we want it to open when a user clicks on the button, now we need to actually open it. In Bootstrap 4, opening a modal involves 3 steps:

  1. A modal-open class needs to be applied to the <body> element.
  2. A show class needs to be applied to the modal element, along with a display: block; inline style attribute.
  3. The following markup needs to be injected to the end of our <body> tag: <div class="modal-backdrop fade show"></div> (this adds the light gray overlay behind the modal).

First, we need to add a modal-open class to the <body> element. How do we do that? Don't forget, Stimulus classes are really just basic JavaScript classes. So if we need to add a class to the body element, we can do it exactly how we'd normally do it in pure JavaScript:

// src/controllers/modal-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
open() {
document.body.classList.add("modal-open");
}
}

Next, we need to add a show class to our modal element, along with display: block; as an inline style attribute. You may remember from earlier that we have access to our modal element easily via Stimulus' this.element property. And once again, we can use good old-fashioned JavaScript methods to accomplish both of those tasks easily:

// src/controllers/modal-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
open() {
document.body.classList.add("modal-open");
this.element.setAttribute("style", "display: block;");
this.element.classList.add("show");
}
}

Look at us go! Now we just need to inject the <div class="modal-backdrop fade show"></div> markup at the end of the body tag to give us the classic light gray modal overlay. We can accomplish that via the innerHtml property on document.body:

// src/controllers/modal-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
open() {
document.body.classList.add("modal-open");
this.element.setAttribute("style", "display: block;");
this.element.classList.add("show");
document.body.innerHTML += '<div class="modal-backdrop fade show"></div>';
}
}

Boom! If we go back to our page now and click the button, a Bootstrap modal should appear. We have a problem though: we opened the modal but we can't close it! To solve this, let's add a close action to our modal controller that just reverses everything the open action did:

// src/controllers/modal-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
open() {
document.body.classList.add("modal-open");
this.element.setAttribute("style", "display: block;");
this.element.classList.add("show");
document.body.innerHTML += '<div class="modal-backdrop fade show"></div>';
}
 
close() {
document.body.classList.remove("modal-open");
this.element.removeAttribute("style");
this.element.classList.remove("show");
document.getElementsByClassName("modal-backdrop")[0].remove();
}
}

We'll also add a button to our modal markup that calls the close action for us:

// public/index.html
<body>
<div id="app" data-controller="demo">
<button data-action="click->demo#launchDemo">Launch Demo</button>
 
<div class="modal" data-controller="modal" data-target="demo.modal">
<div class="modal-dialog">
<div class="modal-content">
This is a modal
<button data-action="click->modal#close">Close</button>
</div>
</div>
</div>
</div>
</body>

Now if you refresh your page, open the modal, then click the close button, the modal will close.

But Wait, There's More...

At this point, we've successfully completed our original goal of taking a standard Bootstrap modal and turning it into a Stimulus controller. But in the real world, modals rarely have fixed content. Instead, they are heavily customized; the content often depends on what you clicked to trigger it. So let's alter our example to be a tiny bit more complex.

In our updated example, rather than just having a single button, we'll have a button for each co-host of the Twenty Percent Time podcast (Daniel Coulbourne and Caleb Porzio). Clicking on the button for each co-host will fill the modal with personalized content about them (their full name, email, and job title). First, let's update our markup to reflect this change:

// public/index.html
<body>
<div id="app" data-controller="demo">
<ol>
<li><button data-action="click->demo#launchDemo">Daniel</button></li>
<li><button data-action="click->demo#launchDemo">Caleb</button></li>
</ol>
 
<div class="modal" data-controller="modal" data-target="demo.modal">
<div class="modal-dialog">
<div class="modal-content">
This is a modal
<button data-action="click->modal#close">Close</button>
</div>
</div>
</div>
</div>
</body>

Next, if we're going to do something different depending on which co-host was clicked, we'll need a way to figure out which button was clicked inside our launchDemo action.

Earlier, we discussed how every Stimulus action receives an event object by default. There's nothing special about this object; it's just a basic JavaScript event. As such, we have access to a property called currentTarget which returns the element that the event handler was attached to. In our case, because our actions are attached to each button, currentTarget will return whichever button was clicked.

Let's update our launchDemo action to test this out:

// src/controllers/demo-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
static targets = ["modal"];
 
launchDemo(event) {
let modalController = this.application.getControllerForElementAndIdentifier(
this.modalTarget,
"modal"
);
modalController.open();
 
console.log(event.currentTarget);
}
}

If you save this change, refresh your page and click on each button, you'll notice that each log in your console reflects which button was clicked.

So now we know which button was clicked, but a bigger problem remains. How do we tell Stimulus what we want the content of the modal to be for each co-host? In other frameworks, each button might be its own component with its own state. But in Stimulus, all we have is our real DOM.

Let's take a step back and think about that for a second. Does the real DOM have any way to give elements their own "state"? I'd argue it does, and that we've been using it this entire time. That's right, I'm talking about data attributes.

Data Attributes as State

Throughout this walkthrough, we've repeatedly used data-controller, data-target, and data-action attributes. These specific attributes are custom to Stimulus, but of course, we can apply any attribute we want to any element we want. For our demo, the co-host information that relates to each button is essentially metadata for that button. And storing metadata on an element is exactly what data attributes are for. So let's add some data attributes to our buttons representing the full name, email, and job title of each co-host:

// public/index.html
<body>
<div id="app" data-controller="demo">
<ol>
<li><button data-action="click->demo#launchDemo" data-name="Daniel Coulbourne" data-email="daniel@tighten.co" data-title="Developer">Daniel</button></li>
<li><button data-action="click->demo#launchDemo" data-name="Caleb Porzio" data-email="caleb@tighten.co" data-title="Developer">Caleb</button></li>
</ol>
 
<div class="modal" data-controller="modal" data-target="demo.modal">
<div class="modal-dialog">
<div class="modal-content">
This is a modal
<button data-action="click->modal#close">Close</button>
</div>
</div>
</div>
</div>
</body>

Once again, we need nothing more than pure JavaScript to access these attributes inside our controller. The event.currentTarget attribute we discussed earlier has a property called dataset which gives us access to all the data attributes on the element. Let's update our launchDemo action to console.log the dataset out:

// src/controllers/demo-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
static targets = ["modal"];
 
launchDemo(event) {
let modalController = this.application.getControllerForElementAndIdentifier(
this.modalTarget,
"modal"
);
modalController.open();
 
console.log(event.currentTarget.dataset);
}
}

Now if you click on a button, you should see the data attributes specific to that button in your console:

Screenshot of console with log showing data attributes of clicked buttons

Our controller now knows two things that it didn't know earlier: which button was clicked, and what the data attributes are for that button. That leaves us with one final problem: how do we get that content into our modal?

Injecting Content into the Modal

Before we can inject the content into our modal, we need to tell Stimulus where we want that content to go in our HTML. Let's add an <h2> element for the name of the host, a <span> element for their job title, and an <a> tag for their email. Additionally, since we know we're soon going to use Stimulus to change the content of these elements, let's make them targets of the modal.

// public/index.html
<body>
<div id="app" data-controller="demo">
<ol>
<li><button data-action="click->demo#launchDemo" data-name="Daniel Coulbourne" data-email="daniel@tighten.co" data-title="Developer">Daniel</button></li>
<li><button data-action="click->demo#launchDemo" data-name="Caleb Porzio" data-email="caleb@tighten.co" data-title="Developer">Caleb</button></li>
</ol>
 
<div class="modal" data-controller="modal" data-target="demo.modal">
<div class="modal-dialog">
<div class="modal-content">
<h2 data-target="modal.name"></h2>
<span data-target="modal.title"></span>
<a data-target="modal.email" href=""></a>
<button data-action="click->modal#close">Close</button>
</div>
</div>
</div>
</div>
</body>

Likewise, we'll need to update our modal controller to add these new targets to our static targets array:

// src/controllers/modal-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
static targets = ['name', 'title', 'email'];
 
open() {
document.body.classList.add("modal-open");
this.element.setAttribute("style", "display: block;");
this.element.classList.add("show");
document.body.innerHTML += '<div class="modal-backdrop fade show"></div>';
}
 
close() {
document.body.classList.remove("modal-open");
this.element.removeAttribute("style");
this.element.classList.remove("show");
document.getElementsByClassName("modal-backdrop")[0].remove();
}
}

With the targets created and connected, we can add a new action to our modal controller called setCoHostContent. We'll use this action to update the content of the modal targets with the data from the custom data attributes on our buttons:

// src/controllers/modal-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
static targets = ['name', 'title', 'email'];
 
setCoHostContent(data) {
this.nameTarget.innerHTML = data.name;
this.titleTarget.innerHTML = data.title;
this.emailTarget.href = 'mailto:' + data.email;
this.emailTarget.innerHTML = data.email;
}
 
open() {
document.body.classList.add("modal-open");
this.element.setAttribute("style", "display: block;");
this.element.classList.add("show");
document.body.innerHTML += '<div id="modal-backdrop" class="modal-backdrop fade show"></div>';
}
 
close() {
document.body.classList.remove("modal-open");
this.element.removeAttribute("style");
this.element.classList.remove("show");
document.getElementsByClassName("modal-backdrop")[0].remove();
}
}

Now that this function exists, we can call it from our launchDemo action, making a note to pass in the dataset:

// src/controllers/demo-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
static targets = ["modal"];
 
launchDemo(event) {
let modalController = this.application.getControllerForElementAndIdentifier(
this.modalTarget,
"modal"
);
modalController.setCoHostContent(event.currentTarget.dataset);
modalController.open();
}
}

If you refresh your page and click on the buttons, you'll notice the modal content now changes depending on which button you click!

This is great. We've accomplished our goal of opening the modal with Stimulus, and even customized its content.

But if you're like me, something just doesn't feel right. There are three actions inside our modal controller: open, close, and setCoHostContent. Open and close are generic, high-level actions that could apply to any modal containing any content. But setCoHostContent? This action is specific to our use case of a modal that displays information about the co-hosts of Twenty Percent Time.

Multiple Controllers

This gives us the perfect opportunity to talk about something we briefly mentioned earlier: elements aren't limited to being a single controller. In fact, an element can be connected to as many controllers as you want!

Because our setCoHostContent method is very specific to our co-host modal, maybe it would be better suited in a controller--let's call it, you guessed it, "co-host modal." Inside your src/controllers directory, create a new controller called co-host-modal-controller.js and paste in the standard Stimulus controller code with an initialize method that confirms the connection:

// src/controllers/co-host-modal-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
initialize() {
console.log("Connected to co-host-modal");
}
}

Now let's return to our modal HTML code, and add co-host-modal to the data-controller property:

// public/index.html
<body>
<div id="app" data-controller="demo">
<ol>
<li><button data-action="click->demo#launchDemo" data-name="Daniel Coulbourne" data-email="daniel@tighten.co" data-title="Developer">Daniel</button></li>
<li><button data-action="click->demo#launchDemo" data-name="Caleb Porzio" data-email="caleb@tighten.co" data-title="Developer">Caleb</button></li>
</ol>
 
<div class="modal" data-controller="modal co-host-modal" data-target="demo.modal">
<div class="modal-dialog">
<div class="modal-content">
<h2 data-target="modal.name"></h2>
<span data-target="modal.title"></span>
<a data-target="modal.email" href=""></a>
<button data-action="click->modal#close">Close</button>
</div>
</div>
</div>
</div>
</body>

If you refresh your page now, not only should the modal still work, but you should see "Connected to co-host-modal" in your console. Our modal component is now connected to two different controllers.

Let's move our setCoHostContent method from our modal controller to our co-host-modal controller, as well as the relevant targets:

// src/controllers/co-host-modal-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
static targets = ['name', 'title', 'email'];
 
setCoHostContent(data) {
this.nameTarget.innerHTML = data.name;
this.titleTarget.innerHTML = data.title;
this.emailTarget.href = 'mailto:' + data.email;
this.emailTarget.innerHTML = data.email;
}
}

We'll also need to update the relevant data-target attributes in our HTML to be prefixed with co-host-modal instead of modal:

// public/index.html
<body>
<div id="app" data-controller="demo">
<ol>
<li><button data-action="click->demo#launchDemo" data-name="Daniel Coulbourne" data-email="daniel@tighten.co" data-title="Developer">Daniel</button></li>
<li><button data-action="click->demo#launchDemo" data-name="Caleb Porzio" data-email="caleb@tighten.co" data-title="Developer">Caleb</button></li>
</ol>
 
<div class="modal" data-controller="modal co-host-modal" data-target="demo.modal">
<div class="modal-dialog">
<div class="modal-content">
<h2 data-target="co-host-modal.name"></h2>
<span data-target="co-host-modal.title"></span>
<a data-target="co-host-modal.email" href=""></a>
<button data-action="click->modal#close">Close</button>
</div>
</div>
</div>
</div>
</body>

If you return to your page and click on the buttons again, you might notice something strange. The modal doesn't even open any more, and we're getting an error in our console. This is because inside our launchDemo method, we're still calling setCoHostContent on our modal controller. We need to update it so that we call it on our co-host-modal instead:

// src/controllers/demo-controller.js
import { Controller } from "stimulus";
 
export default class extends Controller {
static targets = ["modal"];
 
launchDemo(event) {
let modalController = this.application.getControllerForElementAndIdentifier(
this.modalTarget,
"modal"
);
let coHostModalController = this.application.getControllerForElementAndIdentifier(
this.modalTarget,
"co-host-modal"
);
coHostModalController.setCoHostContent(event.currentTarget.dataset);
modalController.open();
}
}

If you refresh your page for a final time, the modal not only opens, but is filled with content specific to each co-host.

Conclusion

We have successfully taken a traditional modal component from a third-party framework (Bootstrap) and transformed it into a slim Stimulus controller that we could use anywhere on any site, without requiring a virtual DOM or pulling in a bulky third-party JavaScript library full of components we don't plan to use.

At this point, it shouldn't be difficult to imagine other use cases where Stimulus could be useful. How many sites have you built lately that use a hamburger menu? Or an image slider? React or Vue might feel overpowered for small interactive elements like that, but Stimulus is a perfect fit.

Do I think Stimulus is going to make virtual DOM frameworks obsolete? Absolutely not! Sites with large, complex state systems would likely be difficult to maintain without tools like those. But if you find yourself working in a monolith needing a tiny bit of interaction, Stimulus may be just what you need.

Questions? Comments? I'm @imjohnbon on Twitter and we're @tightenco.

Get our latest insights in your inbox:

By submitting this form, you acknowledge our Privacy Notice.

Hey, let’s talk.

By submitting this form, you acknowledge our Privacy Notice.

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.

Thank you!

We appreciate your interest. We will get right back to you.