A version of this first appeared in the Viget blog.
If you are wondering what a "Viget module" is, you can read How does Viget JavaScript?
During a recent pointless weekend I got the chance to try out Stimulus. I immediately started thinking of how we could move from our current approach to writing JavaScript to the Stimulus (and Hotwire) approach. I figured the easiest way to share this with the team would be to take an existing tab module we built and convert it to a Stimulus Controller. In this post I try to highlight the benefits of making that change.
Jump to heading Introduction
My goal is to show front-end developers at Viget how we might start using Stimulus, and to highlight some key improvements. Aside from that, I wanted to find out if we could update our approach to match the functionality Stimulus gives us (spoiler: we could, but it's probably not worth it since Stimulus exists).
We've also started thinking about using things like Turbo and Sprig. This poses a challenge to the way we currently write vanilla JS—we can no longer rely on page loads as the main way our DOM changes. Stimulus could solve this problem for us.
What follows is a side-by-side comparison from installation to core functionality of a tabs component.
Jump to heading Installation
The Stimulus installation is taken from the "Using Webpack" section of the handbook. It uses Webpack's require.context
helper to load controllers in a ./controllers
directory.
On the other hand the data-module
pattern asynchronously loads modules in a ./modules
directory. It uses dynamic imports for code splitting, this way modules are only loaded in pages that use them.
import { Application } from "stimulus"
import { definitionsFromContext } from "stimulus/webpack-helpers"
const application = Application.start()
const context = require.context("./controllers", true, /\.js$/)
application.load(definitionsFromContext(context))
const dataModules = [...document.querySelectorAll('[data-module]')]
dataModules.forEach((element) => {
element.dataset.module.split(' ').forEach(function (moduleName) {
import(
`./modules/${moduleName}`
).then((Module) => {
new Module.default(element)
})
})
})
Stimulus starts the application and then loads each of the controller files. Internally, Stimulus waits until the DOM has loaded and then creates MutationObservers
that check for changes on the Document, on each controller's Element, and on attributes. DOM changes trigger controller methods like connect()
, disconnect()
, [valueName]ValueChanged()
.
"Stimulus connects and disconnects these controllers and their associated event handlers whenever the document changes using the MutationObserver API." -- from the Stimulus handbook
Stimulus is designed to work with Turbo—an HTML over the wire framework that gives you the speed of an single page application (SPA) without having to write much JavaScript—so this approach ensures everything works as pages change without a full browser refresh.
Viget modules, on the other hand, are designed to work with full page loads—usually as part of the front-end stack of a Craft CMS build out. Every time a page loads we look for every data-module
attribute and initialize the corresponding module. This approach wouldn't just work™ if we were to add Turbo to our sites. Modules that don't appear on the first page load would never be instantiated.
The above installation code—and the rest of the post—assumes a folder structure like this one:
src/
-- app.js
-- controllers/
---- tabs_controller.js
src/
-- app.js
-- modules/
---- tabs.js
The important thing to note here is the app.js
file lives at the same level as modules/
or controllers/
. If you look at the code above you'll see we reference those directories specifically.
The first takeaway
Viget modules are designed for full page loads. Stimulus controllers are designed to work with frameworks like Turbo that change the DOM without performing full page loads. If we wanted to use our current approach with something like Sprig we would have to rethink our approach when dealing with HTML that Sprig components load after the initial page load. I could stop here since this is the biggest takeaway, Stimulus doesn't care how you change the DOM, it will listen to changes and let you act on those changes. The use of
MutationObserver
API is the killer feature.
Jump to heading HTML Structure
The HTML structure doesn't change beyond changing attributes in the markup. Here is a comparison of the HTML structure in its entirety:
<div data-controller="tabs" data-tabs-index-value="0" data-action="keyup->tabs#cycleTabs">
<div>
<ul role="tablist">
<li role="presentation">
<button id="control-one" role="tab" aria-selected="true" data-action="tabs#selectTab" data-tabs-target="control">
Tab one
</button>
</li>
<!-- Tab two, Tab three, etc -->
</ul>
</div>
<section data-tabs-target="panel" role="tabpanel" tabindex="-1" aria-labelledby="control-one">
Content for tab one
</section>
<!-- Content for tab two, tab three, etc -->
</div>
<div data-module="tabs">
<div>
<ul role="tablist">
<li role="presentation">
<button id="control-one" role="tab" aria-selected="true" data-target="panel-one">
Tab one
</button>
</li>
<!-- Tab two, Tab three, etc -->
</ul>
</div>
<section id="panel-one" role="tabpanel" tabindex="-1" aria-labelledby="control-one">
Content for tab one
</section>
<!-- Content for tab two, tab three, etc -->
</div>
Jump to heading Going over the highlighted differences:
Line 1
:
<div
data-controller="tabs"
data-tabs-index-value="0"
data-action="keyup->tabs#cycleTabs"
>
<div data-module="tabs">
- Stimulus controller:
data-controller
attribute connects to thetabs_controller
.data-tabs-index-value
attribute is set to0
to manage the tab's state. The tabs will initialize with the 1st tab selected. Changing this value dynamically changes the active tab because Stimulus usesMutationObserver
under the hood to track changes.data-action
attribute is set tokeyup->tabs#cycleTabs
. This attaches thekeyup
event to thetabs_controller
and calls thecycleTabs
method. Adding and removing listeners is done by Stimulus internally.
- Viget module:
data-module
attribute causes thetabs
module to be dynamically imported and instantiated.
Line 5
<button
id="control-one"
role="tab"
aria-selected="true"
data-action="tabs#selectTab"
data-tabs-target="control"
>
<button
id="control-one"
role="tab"
aria-selected="true"
data-target="panel-one"
>
- Stimulus controller:
data-tabs-target
attribute identifies each tab as a "control" target. This will later save us the trouble of querying these elements.data-action
attribute attaches theclick
event to thetabs_controller
and calls theselectTab
method.
Note:
click
is the default event for buttons so we can use the event shorthand and omit "click" (ie.tabs#selectTab
is the same asclick->tabs#selectTab
on a buttonsdata-action
).
- Viget module:
data-target
attribute references theid
for the panel. There is nothing special about this attribute though, it is arbitrary. As we'll see later in the JavaScript there's no "magic" here like whatdata-action
anddata-tabs-target
attributes do.
Line 13
<section
data-tabs-target="panel"
role="tabpanel"
tabindex="-1"
aria-labelledby="control-one"
>
<section
id="panel-one"
role="tabpanel"
tabindex="-1"
aria-labelledby="control-one"
>
- Stimulus controller:
- The
data-tabs-target
attribute identifies each tabpanel as a "panel" target. This will later save us the trouble of querying these elements.
- The
- Viget module:
- The
id
attribute is how we connect controls to panels in. In other words the code we will write for this module uses the value ofdata-target
on the button controls to find the correspondingid
on the panels.
- The
Jump to heading One last thing to note about these examples:
The mark up has been simplified and all classes were removed for clarity. Key things to know are:
aria-selected
is removed on unselected tab controls,tabindex="-1"
is set on unselected tab controls,- the
hidden
attribute is added to unselected tab panels, - the
id
s are unique for each control and panel, - and the
data-target
on the Viget module tab controls always match the corresponding tab panelid
.
The second takeaway
Stimulus defines state (values), elements (targets), and behaviours (actions) in the HTML. The framework is able to take care of common tasks like querying elements and listening to state changes. This has ergonomic benefits but also makes it clear that HTML is the source of truth when working with Stimulus controllers.
Jump to heading The JavaScript
Stimulus is a framework. That means it provides a specific structure for writing controllers. On the other hand a Viget module is more like a recipe—you can really do whatever you want. In the example we'll follow the typical way we would approach writing a module.
Jump to heading The following sections cover:
- Imports: Only to point out we are extending the Stimulus
Controller
class. - Initialization: This highlights key differences in how our HTML relates to the JavaScript code we write.
- Handling clicks: We'll see how things differ in terms of changing "state".
- Handling key presses: Same as handling clicks.
- Core functionality: We'll show how Stimulus leads us to structure our code differently than Viget modules.
We'll break down each section of the Stimulus controller and Viget module. Here they are side-by-side in their entirety:
import { Controller } from 'stimulus'
export default class extends Controller {
static targets = ['control', 'panel']
static values = { index: Number }
currentTab = {
control: null,
panel: null,
}
connect() {
this.currentTab = {
control: this.controlTargets[this.indexValue],
panel: this.panelTargets[this.indexValue],
}
}
indexValueChanged() {
if (!this.currentTab.control || !this.currentTab.panel) {
return
}
const { control: prevControl, panel: prevPanel } = this.currentTab
prevControl.removeAttribute('aria-selected')
prevControl.setAttribute('tabindex', -1)
prevPanel.setAttribute('hidden', true)
const control = this.controlTargets[this.indexValue]
const panel = this.panelTargets[this.indexValue]
document.activeElement !== control && control.focus()
control.setAttribute('aria-selected', true)
control.removeAttribute('tabindex')
panel.removeAttribute('hidden')
this.currentTab = {
control,
panel,
}
}
selectTab = (e) => {
this.indexValue = this.controlTargets.indexOf(e.currentTarget)
}
cycleTabs = (e) => {
const onFirst = this.indexValue === 0
const onLast = this.indexValue === this.panelTargets.length - 1
const key = e.code
if (!['ArrowRight', 'ArrowLeft'].includes(key)) return
if (key === 'ArrowRight') {
this.indexValue = onLast ? 0 : this.indexValue + 1
} else {
this.indexValue = onFirst
? this.panelTargets.length - 1
: this.indexValue - 1
}
}
}
export default class Tabs {
constructor(el) {
this.el = el
this.createVars()
this.toggleEvents(true)
}
createVars() {
this.tabControls = [
...this.el.querySelectorAll('[role="tab"][data-target]'),
]
this.currentTab = this.tabControls[0]
}
toggleEvents = (add) => {
const method = add ? 'addEventListener' : 'removeEventListener'
this.tabControls.forEach((el) => {
el[method]('click', this.handleTabClick)
el[method]('keydown', this.handleKeydown)
})
}
cleanUp = () => {
this.toggleEvents(false)
}
handleTabClick = (e) => {
const clickedTab = e.currentTarget
if (clickedTab === this.currentTab) return
this.switchTabs(clickedTab)
}
handleKeydown = (e) => {
const key = e.code
if (!['ArrowRight', 'ArrowLeft'].includes(key)) return
const min = 0
const max = this.tabControls.length - 1
let index = this.tabControls.indexOf(e.currentTarget)
index = key === 'ArrowRight' ? index + 1 : index - 1
index = Math.min(Math.max(index, min), max)
this.tabControls[index].focus()
this.switchTabs(this.tabControls[index])
}
switchTabs = (newTab) => {
this.hideCurrentTab()
this.showTab(newTab)
}
hideCurrentTab() {
const currentPanel = document.getElementById(this.currentTab.dataset.target)
this.currentTab.removeAttribute('aria-selected')
this.currentTab.setAttribute('tabindex', -1)
currentPanel.setAttribute('hidden', true)
}
showTab(control) {
const panelToShow = document.getElementById(control.dataset.target)
control.setAttribute('aria-selected', true)
control.removeAttribute('tabindex')
panelToShow.removeAttribute('hidden')
this.currentTab = control
}
}
Jump to heading 1. Imports
Stimulus controllers extend a Controller
base class while Viget modules have no dependencies—it's just a pattern to follow based on our installation code (async module importing).
Stimulus controller:
We import the Controller
base class and extend it to create our own controller. Controllers belong to an application. If you recall, in the "Installation" section we loaded all of our controllers with application.load()
. Then in the HTML structure section, we used data-controller
to identify our controller. That determined the scope of the controller. Inside the controller you could access this.element
aka the element with the data-controller
attribute.
Viget module:
We just export our module which is asynchronously imported in our entry point. Look back to the "Installation" section and you'll see where the dynamic imports happen.
import { Controller } from 'stimulus'
export default class extends Controller {
// ...
}
export default class Tabs {
// ...
}
The third takeaway
Stimulus has an
application
that orchestratescontrollers
. Whencontrollers
are initialized theapplication
pre-loads thecontroller
with lifecycle callbacks, targets, actions, and values—refer back to the HTML section to see targets, actions, and values being set. A framework would cut down the amount of boilerplate we copy from module to module.
Jump to heading 2. Initializing
Both approaches require some setting up, but most of the "setup" for the Stimulus controller happens behind the scenes aided by markup we've written. The data-module
pattern is a bit more manual.
Stimulus controller:
There is not much to initialize since Stimulus bootstraps everything internally for us. In the HTML we've already initialized state with values, set up event handlers with actions, and identified our targets. Inside the connect
life-cycle method we just set the currentTab
to the active control and panel targets based on the current index value.
Viget module:
Meanwhile, the Viget module needs to create variables and attach events. Modules are instantiated with the element that has the data-module
attribute. We usually assign this element to this.el
for convenience.
Creating variables consists of identifying target elements and initializing state. In this case we are querying for all the tab controls and assigning this.currentTab
to the first control.
Event listeners are attached to all the controls in toggleEvents
. We use a toggle so we can remove all listeners with the same method. The cleanUp
does just that.
Note:
cleanUp
is the method we use to reset module state during development where we use HMR (hot module replacement) to avoid refreshing the page with every change.
export default class extends Controller {
static targets = ['control', 'panel']
static values = { index: Number }
currentTab = {
control: null,
panel: null,
}
connect() {
this.currentTab = {
control: this.controlTargets[this.indexValue],
panel: this.panelTargets[this.indexValue],
}
}
// ...
}
export default class Tabs {
constructor(el) {
this.el = el
this.createVars()
this.toggleEvents(true)
}
createVars() {
this.tabControls = [
...this.el.querySelectorAll('[role="tab"][data-target]'),
]
this.currentTab = this.tabControls[0]
}
toggleEvents = (add) => {
const method = add ? 'addEventListener' : 'removeEventListener'
this.tabControls.forEach((el) => {
el[method]('click', this.handleTabClick)
el[method]('keydown', this.handleKeydown)
})
}
cleanUp = () => {
this.toggleEvents(false)
}
// ...
}
The Fourth takeaway
Stimulus abstracts most of our initialization code. In any given project we could have many of these modules doing repetitive event binding and querying elements. This abstraction goes beyond just "writing less code" it lets us focus on what's important in our module—the actual functionality we are adding.
Jump to heading 3. Handling clicks
At this point we take two different approaches, and some advantages of Stimulus become apparent.
Stimulus controller:
Because our state management is happening on the DOM, via the data-tabs-index-value
attribute, we are going to change that value on click. Values have special methods that listen for changes, Stimulus observes changes and calls the method on our controller (in this case the indexValueChanged
method that we'll go over later). If you are familiar with React this might feel similar to using a useEffect
hook and having indexValue
in your dependency array:
React.useEffect(() => {
// do stuff when indexValue changes
}, [indexValue])
Viget module:
By contrast the Viget module approach checks if we've clicked the current tab and then calls a switchTabs
method with the clicked tab. switchTabs
then calls two methods: hideCurrentTab
and showTab
. We have no real concept of state in a module, or at least no unified way of handling it. This can at times become confusing or hard to follow.
export default class extends Controller {
// ...
selectTab = (e) => {
this.indexValue = this.controlTargets.indexOf(e.currentTarget)
}
// ...
}
export default class Tabs {
// ...
handleTabClick = (e) => {
const clickedTab = e.currentTarget
if (clickedTab === this.currentTab) return
this.switchTabs(clickedTab)
}
// ...
}
The fifth takeaway
Stimulus provides a layer of state management that our modules lack. This makes the code more predictable and easier to follow. We can react to value changes in their
[valueName]ValueChanged
methods, and always keep the DOM as the source of truth. There's no reason our modules couldn't implement this too, but nothing about our pattern encourages this in the way the Stimulus framework does.
Jump to heading 4. Handling left and right arrow keys
The code here is identical and everything said about handling clicks applies here. The only difference is that reacting to indexValue
changing is so easy I decided to make the tabs loop around when they reach the end or beginning (but that wouldn't be particularly hard to implement in a Viget module).
export default class extends Controller {
// ...
cycleTabs = (e) => {
const onFirst = this.indexValue === 0
const onLast = this.indexValue === this.panelTargets.length - 1
const key = e.code
if (!['ArrowRight', 'ArrowLeft'].includes(key)) return
if (key === 'ArrowRight') {
this.indexValue = onLast ? 0 : this.indexValue + 1
} else {
this.indexValue = onFirst
? this.panelTargets.length - 1
: this.indexValue - 1
}
}
// ...
}
export default class Tabs {
// ...
handleKeydown = (e) => {
const key = e.code
if (!['ArrowRight', 'ArrowLeft'].includes(key)) return
const min = 0
const max = this.tabControls.length - 1
let index = this.tabControls.indexOf(e.currentTarget)
index = key === 'ArrowRight' ? index + 1 : index - 1
index = Math.min(Math.max(index, min), max)
this.tabControls[index].focus()
this.switchTabs(this.tabControls[index])
}
// ...
}
Jump to heading 5. Core Functionality
This is the code that modifies the DOM and changes the tabs.
Stimulus controller:
Like I mentioned in the "Handling clicks" section we make all our changes inside the indexValueChanged
method that is called any time the indexValue
is changed. You'll notice we do select anything in this method since our controller already holds all the information we need via targets, values, and a currentTab
property we created.
Everything we do inside the indexValueChanged
is not Stimulus specific, we do the same things in the Viget module. The difference is we react to a value change not a particular tab control click or keyup event. The advantage isn't fully apparent in this example, but we could change the indexValue
from anywhere else and our tabs would be updated (e.g., from websockets, or a timer change). The source of truth is whatever our HTML shows as the indexValue
:
<div data-tabs-index-value="0">
<!-- data-tabs-index-value change be changed by anything to control the tabs -->
</div>
This isn't true of our Viget module. Notice we tied the controls to tabs with data attributes and ids that reference each other.
Viget module:
The important difference to note is that everything is far more "manual". We are calling various functions in order to respond to the key press or click. It's worth mentioning we could implement Stimulus-like behaviour, but it would take quite a bit more code to do so.
export default class extends Controller {
// ...
indexValueChanged() {
if (!this.currentTab.control || !this.currentTab.panel) {
return
}
const { control: prevControl, panel: prevPanel } = this.currentTab
prevControl.removeAttribute('aria-selected')
prevControl.setAttribute('tabindex', -1)
prevPanel.setAttribute('hidden', true)
const control = this.controlTargets[this.indexValue]
const panel = this.panelTargets[this.indexValue]
document.activeElement !== control && control.focus()
control.setAttribute('aria-selected', true)
control.removeAttribute('tabindex')
panel.removeAttribute('hidden')
this.currentTab = {
control,
panel,
}
}
// ...
}
export default class Tabs {
// ...
switchTabs = (newTab) => {
this.hideCurrentTab()
this.showTab(newTab)
}
hideCurrentTab() {
const currentPanel = document.getElementById(this.currentTab.dataset.target)
this.currentTab.removeAttribute('aria-selected')
this.currentTab.setAttribute('tabindex', -1)
currentPanel.setAttribute('hidden', true)
}
showTab(control) {
const panelToShow = document.getElementById(control.dataset.target)
control.setAttribute('aria-selected', true)
control.removeAttribute('tabindex')
panelToShow.removeAttribute('hidden')
this.currentTab = control
}
// ...
}
The sixth takeaway
We could, of course, change how any Viget module works to be more flexible and do all the things the Stimulus controller is able to do, but it would take additional work. Stimulus is just built to work this way and that makes it a powerful framework for web applications hoping to match what you can do with SPAs.
Jump to heading Conclusion
To recap the takeaways:
- Viget modules are designed for full page loads while Stimulus is designed to work no matter how the DOM is being modified. This lets us build SPA-like experiences with HTML over the wire. The use of the
MutationObserver
API is Stimulus' killer feature. - Stimulus provides ergonomic advantages through actions, values, and targets defined as attributes on HTML elements.
- We can cut down the amount of boilerplate we write because Stimulus takes care of common tasks behind the scenes and provides us lifecycle callbacks, targets, actions, and values to use in our Controllers.
- We can write less code thanks to Stimulus' abstractions, this lets us focus on the actual functionality we are trying to add.
- Stimulus adds a unified way of thinking about state management that we currently lack. It also gives us callbacks to react to value changes that don't necessarily have to come from our Controller, they can come from anything that can change the DOM.
- We could add this functionality, but why would be if we can use Stimulus?
I am pumped to try out Stimulus (and Turbo) in my next project.
Jump to heading PS.
Special thanks to Trevor for sharing his tabs module for this blog post.
If you want to know everything about these tabs check out the "Tabbed Interfaces" article Trevor used to build them.