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.
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.
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.
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.
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.
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.jsimport { 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?
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.jsimport { 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 toconnect
anddisconnect
). 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.jsimport { 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:
Fantastic; we've confirmed our connection.
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.jsimport { 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 thetargets
property may look unfamiliar to you. Static properties are not included by default in ES6, but thestimulus-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.jsimport { 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.
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.jsimport { 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.log
s 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:
click->
, which tells Stimulus what DOM event to listen for. Other events are outlined in the documentation.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.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:
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?
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.jsimport { 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.jsimport { 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.jsimport { 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
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.jsimport { 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:
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.jsimport { 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.
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:
modal-open
class needs to be applied to the <body>
element.show
class needs to be applied to the modal element, along with a display: block;
inline style attribute.<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.jsimport { 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.jsimport { 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.jsimport { 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.jsimport { 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.
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.jsimport { 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.
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.jsimport { 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:
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?
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.jsimport { 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.jsimport { 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.jsimport { 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.
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.jsimport { 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.jsimport { 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.jsimport { 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.
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.
We appreciate your interest.
We will get right back to you.