This article was originally published on Rails Designer
I have written a fair amount on the basics of Stimulus, but I never wrote about the absolute basics: what actually is a "Stimulus controller"? That is why I want to cover some basics. This article was taken from various parts of the book JavaScript for Rails Developers
The foundation of Stimulus is the “controller”, which serves as the primary building block for organizing your interactive features. Stimulus encourages you to write small, reusable controllers. Opposed to component-specific controllers (though those have their place too!).
A Stimulus controller is essentially a regular JavaScript class. So, what does that look like?
class Editor {
constructor(content) {
this.content = content
}
save() {
console.log(this.content)
}
}
The key difference is that a Stimulus controller extends the base Controller
class from Stimulus (extends Controller
). This inheritance provides all the built-in Stimulus features like lifecycle methods, target definitions, and value definitions. I have wrote about inheritance before. It functions similarly to Rails controller, e.g. EditorController that inherits from ApplicationController.
Anonymous vs. non-anonymous classes
The controller is typically defined as an anonymous class:
export default class extends Controller
However, you could also write it as a non-anonymous class:
export default class EditorController extends Controller
The anonymous approach is more common and preferred in most Stimulus applications because it follows established conventions and keeps the code simpler. It is what I prefer and suggest to use as well. The controller's identity comes from its filename (editor_controller.js), not the class name itself, which makes the anonymous syntax feel natural. This approach also aligns with the patterns you'll see throughout Stimulus documentation and community examples.
The non-anonymous approach can be valuable in certain situations, particularly when debugging complex applications (though it is a “Hotwire smell” in my opinion if you have this many Stimulus controllers!). Named classes appear more clearly in browser developer tools and stack traces, making it easier to track down issues. Some teams with established coding standards might prefer the explicit naming for consistency across their codebase.
Features of Controller
The Controller class provides more features through static properties, which are just regular JavaScript class features that Stimulus reads during initialization. These static declarations tell Stimulus how to wire up your controller to the DOM.
export default class extends Controller {
static targets = ["input", "output"]
static values = { content: String, count: Number }
static classes = ["active", "hidden"]
connect() {
console.log("Controller connected!")
}
inputTargetConnected() {
this.inputTarget.focus()
}
}
The static targets
property defines which DOM elements within the controller's scope you want to reference directly. Stimulus automatically creates properties like this.inputTarget
and this.outputTarget
based on these declarations. Similarly, static values
creates reactive properties that automatically update when their corresponding HTML data attributes change, while static classes
provides a clean way to manage CSS classes.
These static properties leverage standard JavaScript class syntax and are not unique to Stimulus. They are simply class properties that Stimulus reads when setting up your controller.
You could achieve the same result with regular JavaScript, but it would require significantly more boilerplate code to handle the DOM queries, attribute watching, and cleanup that Stimulus provides automatically.
For example: the static targets
feature. With Stimulus, you simply declare:
export default class extends Controller {
static targets = ["input"]
connect() {
this.inputTarget.focus()
}
}
To achieve the same functionality with vanilla JavaScript, you could write:
class Editor {
constructor(element) {
this.inputTarget = element.querySelector('[data-editor-target="input"]')
if (!this.inputTarget) {
throw new Error("Missing required target: input")
}
this.observer = new MutationObserver(() => {
this.inputTarget = this.element.querySelector('[data-editor-target="input"]')
})
this.observer.observe(element, { childList: true, subtree: true })
}
connect() {
this.inputTarget.focus()
}
disconnect() {
this.observer.disconnect()
}
}
Stimulus handles all the target querying, error checking, and dynamic updates automatically, letting you focus on the actual behavior instead of the plumbing. The same is done for the other defined static targets
properties.
Connecting controllers on the page
To me, the best feature of Stimulus controllers lies in their automatic instantiation. With regular JavaScript classes, you need to manually create instances and wire them up to DOM elements. This might look like:
// In app/assets/javascripts/application.js
import Editor from "./editor"
document.addEventListener("DOMContentLoaded", function() {
const editorElement = document.querySelector("[data-editor]")
if (editorElement) {
const editor = new Editor(editorElement.dataset.content)
// wire up event listeners, etc.
}
})
Stimulus uses the MutationObserver API. This browser API allows Stimulus to watch for changes in the DOM and automatically detect when elements with data-controller
attributes are added or removed. When Stimulus spots a new element with data-controller="editor"
, it automatically creates an instance of the corresponding controller class and connects it to that element.
And that is the basics for Stimulus controllers. If you'd like to know more and broaden your understanding of (modern) JavaScript, check out the book JavaScript for Rails Developers. It covers topics like this and much more in a short, focused, easy-to-follow book.
Top comments (0)