Skip to content

Lesson 1 — Stimulus in depth

What Stimulus is and isn’t

Stimulus is not a frontend framework. It doesn’t manage state, render views, or replace server-rendered HTML. It enhances existing HTML with small, focused JavaScript controllers.

The mental model: HTML is the source of truth. Stimulus reads data-* attributes from the DOM and attaches behaviour. When HTML changes (via Turbo or a server render), Stimulus controllers automatically connect and disconnect.

This makes Stimulus a natural companion to Phlex — Phlex generates semantic HTML with data-* attributes; Stimulus reads them and adds behaviour.

The Stimulus API

A Stimulus controller has four building blocks:

Targets — references to DOM elements within the controller’s scope:

1
2
3
4
static targets = ["input", "results", "count"]
// this.inputTarget       — first matching element
// this.inputTargets      — all matching elements
// this.hasInputTarget    — boolean

Actions — event handlers declared in HTML:

1
data-action="click->search#submit keyup->search#filter"

Multiple actions on one element, multiple events on one action.

Values — typed data passed from HTML to the controller:

1
2
3
4
5
6
7
static values = {
  url:      String,
  delay:    { type: Number, default: 300 },
  multiple: { type: Boolean, default: false }
}
// this.urlValue, this.delayValue, this.multipleValue
// valueChanged callbacks: urlValueChanged(value, previousValue)

Classes — CSS class names declared in HTML, referenced in JavaScript:

1
2
static classes = ["active", "hidden", "loading"]
// this.activeClass, this.hiddenClasses, this.hasLoadingClass

Stimulus in Phlex

In ERB, data-* attributes are typically scattered through templates. In Phlex they are generated by the component itself — the component owns its own wiring:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Components::SearchInput < Components::Base
  prop :url, String

  def view_template
    div(
      data: {
        controller:         "search",
        search_url_value:   @url,
        search_delay_value: 300,
      }
    ) do
      input(
        type: "text",
        data: {
          search_target: "input",
          action:        "keyup->search#filter"
        }
      )
      div(data: { search_target: "results" })
    end
  end
end

The data-* attributes are declared in one place — the component. A developer using SearchInput doesn’t need to know anything about Stimulus; the wiring is encapsulated.

The naming conventions are explicit and readable. search_url_value tells you immediately: this is the url value for the search controller. search_target: "input" tells you: this element is the input target of the search controller. Writing these out in full is more readable than any abstraction over them.

Registering controllers

Rails 8 uses importmaps. The default index.js uses eagerLoadControllersFrom which automatically discovers and registers any file matching *_controller.js in the controllers directory:

1
2
3
4
// app/javascript/controllers/index.js
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)

Create app/javascript/controllers/alert_controller.js and it registers automatically as alert. No import or manual registration needed.

The naming convention: theme_toggle_controller.js registers as theme-toggle — underscores become dashes.

You can also use the Rails generator to create a controller file with the correct name and registration:

1
2
bin/rails generate stimulus alert
# Creates app/javascript/controllers/alert_controller.js

Debugging

The Stimulus DevTools browser extension shows all active controllers, their targets, values, and classes. Install it — it is invaluable when a controller isn’t connecting.

Common reasons a controller doesn’t connect:

  • The data-controller value doesn’t match the registered name
  • The JavaScript file has a syntax error (check the browser console)
  • The element was added to the DOM after Stimulus initialised without Turbo (Turbo handles this automatically)