Stimulus controllers and Viget modules, how do they compare?

Leo Bauza, Former Front-End Development Technical Director

Article Categories: #Code, #Front-end Engineering

Posted on

Understanding what Stimulus gives us that our current tooling doesn't

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.

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.

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.

Javascript
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))
Javascript
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:

Bash
src/
-- app.js
-- controllers/
---- tabs_controller.js
Bash
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.

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:

Markup
<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>
Markup
<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>

Going over the differences: #

Line 1:

Markup
<div 
  data-controller="tabs" 
  data-tabs-index-value="0" 
  data-action="keyup->tabs#cycleTabs"
>
Markup
<div data-module="tabs">
  • Stimulus controller:
    • data-controller attribute connects to the tabs_controller.
    • data-tabs-index-value attribute is set to 0 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 uses MutationObserver under the hood to track changes.
    • data-action attribute is set to keyup->tabs#cycleTabs. This attaches the keyup event to the tabs_controller and calls the cycleTabs method. Adding and removing listeners is done by Stimulus internally.
  • Viget module:
    • data-module attribute causes the tabs module to be dynamically imported and instantiated.

Line 5

Markup
<button 
  id="control-one" 
  role="tab" 
  aria-selected="true" 
  data-action="tabs#selectTab" 
  data-tabs-target="control"
>
Markup
<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 the click event to the tabs_controller and calls the selectTab 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 as click->tabs#selectTab on a buttons data-action).

  • Viget module:
    • data-target attribute references the id 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 what data-action and data-tabs-target attributes do.

Line 13

Markup
<section 
  data-tabs-target="panel"
  role="tabpanel" 
  tabindex="-1" 
  aria-labelledby="control-one" 
>
Markup
<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.
  • 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 of data-target on the button controls to find the corresponding id on the panels.

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 ids are unique for each control and panel,
  • and the data-target on the Viget module tab controls always match the corresponding tab panel id.

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.

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.

The following sections cover: #

  1. Imports: Only to point out we are extending the Stimulus Controller class.
  2. Initialization: This highlights key differences in how our HTML relates to the JavaScript code we write.
  3. Handling clicks: We'll see how things differ in terms of changing "state".
  4. Handling key presses: Same as handling clicks.
  5. 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:

Javascript
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
    }
  }
}
Javascript
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
  }
}

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.

Javascript
import { Controller } from 'stimulus'

export default class extends Controller {
  // ...
}
Javascript
export default class Tabs {
  // ...
}

The third takeaway

Stimulus has an application that orchestrates controllers. When controllers are initialized the application pre-loads the controller 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.

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.

Javascript
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],
    }
  }

  // ...
}
Javascript
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.

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.

Javascript
export default class extends Controller {
  // ...

  selectTab = (e) => {
    this.indexValue = this.controlTargets.indexOf(e.currentTarget)
  }

  // ...
}
Javascript
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.

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).

Javascript
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
    }
  }

  // ...
}
Javascript
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])
  }

  // ...
}

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.

Javascript
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,
    }
  }

  // ...
}
Javascript
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.

Conclusion #

To recap the takeaways:

  1. 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.
  2. Stimulus provides ergonomic advantages through actions, values, and targets defined as attributes on HTML elements.
  3. 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.
  4. We can write less code thanks to Stimulus' abstractions, this lets us focus on the actual functionality we are trying to add.
  5. 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.
  6. 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.

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.

Related Articles