Stimulus was released in 2018 by the Basecamp team. Its motto is “A modest JavaScript framework for the HTML you already have.”. It sounded catchy (and lead by DHH) so I wanted to play around with it and see what it was all about and how it stacks up to more modern reactive frameworks like Vue and React (spoiler alert: it is not at all similar to Vue or React). After playing around with it, I quickly formed my opinion on it and will share that at the end, but first I’ll walk through what it does and how it works.

Stimulus is very different from what you normally think of when you hear “front-end framework”. It is not for creating robust JavaScript applications. It’s built for server rendered HTML that uses JavaScript to sprinkle in some front-end functionality. There is no built-in one way or two way data binding, no state management like you might expect, no component or DOM creation. It’s simply for DOM manipulation and provides a set of tools to help you do that easier. I was honestly drawn to this framework when reading DHH’s Origin of Stimulus introduction article because it fits well with how we wrote JS at Flywheel at the time. Mostly server rendered HTML with sprinkles of JS. Think manipulation, not creation.

Above all, it’s a toolkit for small teams who want to compete on fidelity and reach with much larger teams using more laborious, mainstream approaches

From DHH, the creator of Stimulus

Stimulus was built for Basecamp as an alternative to a heavy front-end driven architecture with routing and data served from a JSON API or endpoints. It was built to live alongside Turbolinks to give Basecamp that snappy SPA feel, but they claim it is not necessarily Rails specific. The intro article on their site is pretty Rails specific, but their examples are not at all.

Controllers, actions & targets

Stimulus is comprised of three main concepts: controllers, actions and targets. Stimulus is heavily driven by your markup. It doesn’t create the HTML, it merely attaches to existing HTML. Their take is that looking at markup should immediately give you a good idea of what’s going on. An excerpt from their docs:

…such that you can look at a single template and know which behavior is acting upon it

Here’s a quick example of what the markup to copy text from an input to your clipboard looks like in Stimulus:

<div data-controller="clipboard">
  <input readonly="readonly" type="text" value="1234" data-target="clipboard.source" />
  <button data-action="click->clipboard#copy">Copy to Clipboard</button>
</div>

Controllers

Controllers are JavaScript objects where you put custom methods. They are connected to the DOM via a data attribute, called an identifier. An element with a clipboard controller identifier (&lt;div data-controller="clipboard"&gt;) tells the Clipboard controller that it’s the containing element to work with.

Stimulus auto loads controllers based on their file name. For instance, javascripts/controllers/clipboard_controller.js would get auto loaded and instantiated whenever a data-controller="clipboard" attribute is present on an element in the DOM.

A skeleton of the clipboard_controller.js file would look like this:

// javascripts/controllers/clipboard_controller.js
import { Controller } from 'stimulus';

export default class extends Controller {
  connect () {
    console.log('Stimulus controller instantiated!');
  }
}

The lifecycle of a Stimulus controller has only three methods: initialize, connect(), and disconnect.

initialize() is invoked once, when the controller is first instantiated. connect() is called anytime the controller is connected to the DOM. disconnect() is called anytime the controller is disconnected from the DOM.

Actions

Actions are glorified event handlers. They are defined as data attributes on the element you want to trigger an action on. They are scoped to a controller, so the data attribute must be on an element underneath the controller.

<div data-controller="clipboard"><button data-action="click->clipboard#copy">Copy to Clipboard</button></div>
export default class extends Controller {
  copy () {
    console.log('Let\'s copy some stuff!');
  }
}

The above code would invoke a copy method on the clipboard controller when the button is clicked. This is how actions are tied to controllers. Instead of selecting DOM elements and adding event listeners in the JS, Stimulus allows you to set them up in the markup. Actions are wrappers for events!

Action descriptors accept any events that addEventListener accepts (i.e. submit, mouseover, etc.). The default is "click", so for click events you can omit the click-> and just write data-action="clipboard#copy".

Targets

The third concept of Stimulus is that of targets. Targets are simply references to DOM elements. They very much remind me of when we used to cache jQuery objects in a big elements object and referenced them later in the JS file. Targets just store references to DOM elements. Stimulus watches the DOM for any data-target attributes and stores them in a static array.

For instance, to reference to add a target to the input in the following markup, add a data-target="clipboard.source" attribute where clipboard is the name of the controller and source is the name of the target you want to add.

<div data-controller="clipboard"><input readonly="readonly" type="text" value="1234" data-target="clipboard.source" />
<button data-action="click->clipboard#copy">Copy to Clipboard</button></div>

You can access that target from the targets array in the controller JS via this.sourceTarget. The pattern is this.__name__Target. You must first, however, define available targets in a target definition in the JS file:

export default class extends Controller {
  static targets = [ 'source' ];

  copy () {
    console.log(this.sourceTarget.value);
  }
}

More information on why the targets definition can be found here.

Stimulus has a few built-in methods for accessing targets:

this.sourceTarget evaluates to the first source target in your controller’s scope.
this.sourceTargets evaluates to an array of all source targets in the controller’s scope.
this.hasSourceTarget returns boolean if there’s a source target or not.

Managing state

Most reactive JS frameworks put data state in the JS. In Stimulus, state lives in attributes in the DOM and has some convenient methods for getting those data attributes.

Data is added to the state via a data-CONTROLLER-statename attribute. That is, data- then the name of the controller, then the custom name of the data attribute. Following our clipboard example:

<div data-controller="clipboard" data-clipboard-index="1"></div>

This would allow us to access the data property index by doing this.data.get('index') in the JS. Each controller has a this.data object and the built-in methods for dealing with it are this.data.get('index') (returns the value), this.data.has('index') (returns true if the value exists) and this.data.set('index', 'value') (sets the value).

Bringing Stimulus into a project

Stimulus can be added to any project with a simple npm install stimulus. To add it to an existing Ruby on Rails application with webpacker, you can use rails webpacker:install:stimulus. Stimulus integrates with webpack to automatically load controller files from a folder in your app.

In summary

Stimulus avoids the heavy lifting as much as possible. In DHH’s intro article he lays out how Basecamp does use heavier frameworks for certain parts of their app, but other parts don’t necessarily need to turn JSON into HTML so Stimulus provides for a nice structure.

In my opinion, Stimulus feels a little outdated. These concepts are very rudimentary and handled better with modern day frameworks like Vue or React, but could be useful for greenfield projects that are solely server side rendered. However, I’ll be the first to admit I get the sentiment here. Traditionally, JS was used to “enhance” the DOM. Not build it. It is, however, a good way to wrangle legacy codebases (ones that have jQuery sprinkled throughout for instance).