Best practices for using Stimulus controllers to add JavaScript behavior to HTML
Rule updated on 12/15/2025 to Stimulus version 3.2.2
Stimulus is a modest JavaScript framework designed to augment your HTML with just enough behavior. It connects JavaScript to the DOM via data attributes, keeping your HTML as the source of truth.
For full reference see https://stimulus.hotwired.dev/
| Concept | Purpose | Data Attribute |
|---|---|---|
| Controller | JavaScript class that adds behavior | data-controller="name" |
| Target | Important elements referenced in JS | data-name-target="targetName" |
| Action | Event handlers connecting DOM to methods | data-action="event->name#method" |
| Value | Reactive data stored in HTML | data-name-value-name="value" |
| Class | CSS classes toggled by the controller | data-name-class-name="class" |
| Outlet | References to other controllers | data-name-outlet-name="selector" |
Use Stimulus for:
Don't use Stimulus for:
Controllers that live in app/javascript/controllers/ and follow the naming convention below are automatically registered.
| File Name | Controller Name | HTML Reference |
|---|---|---|
hello_controller.js |
HelloController |
data-controller="hello" |
clipboard_controller.js |
ClipboardController |
data-controller="clipboard" |
user_form_controller.js |
UserFormController |
data-controller="user-form" |
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "output"]
static values = { url: String, count: Number, active: Boolean }
static classes = ["hidden", "active"]
connect() {
// Called when controller is connected to DOM
}
disconnect() {
// Called when controller is removed from DOM
// Clean up event listeners, timers, etc.
}
// Action methods
toggle() {
this.outputTarget.classList.toggle(this.hiddenClass)
}
}
Targets provide named references to important elements within the controller's scope.
export default class extends Controller {
static targets = ["input", "submit", "error"]
validate() {
if (this.inputTarget.value.length < 3) {
this.errorTarget.textContent = "Too short"
this.submitTarget.disabled = true
}
}
}
<div data-controller="form">
<input data-form-target="input" data-action="input->form#validate">
<span data-form-target="error"></span>
<button data-form-target="submit">Submit</button>
</div>
| Property | Returns | Example |
|---|---|---|
this.inputTarget |
First matching element (or error) | Single element |
this.inputTargets |
Array of all matching elements | [el1, el2, el3] |
this.hasInputTarget |
Boolean if target exists | true / false |
Values are reactive data attributes that automatically sync between HTML and JavaScript.
export default class extends Controller {
static values = {
url: String,
count: Number,
active: Boolean,
config: Object,
items: Array,
}
countValueChanged(value, previousValue) {
// Called automatically when count value changes
console.log(`Count changed from ${previousValue} to ${value}`)
}
}
<div data-controller="counter"
data-counter-count-value="0"
data-counter-url-value="<%= api_path %>"
data-counter-config-value="<%= { limit: 10 }.to_json %>">
</div>
*ValueChanged callbacksstatic values = { count: { type: Number, default: 0 } }Actions connect DOM events to controller methods.
data-action="event->controller#method"
<!-- Click event (default for buttons) -->
<button data-action="dropdown#toggle">Menu</button>
<!-- Explicit event -->
<input data-action="input->search#filter">
<!-- Multiple actions -->
<input data-action="focus->form#highlight blur->form#unhighlight">
<!-- Keyboard events with filters -->
<input data-action="keydown.enter->form#submit keydown.escape->form#cancel">
<!-- Window/document events -->
<div data-controller="modal" data-action="keydown.escape@window->modal#close">
<!-- Form events -->
<form data-action="submit->form#validate">
| Modifier | Effect |
|---|---|
:prevent |
Calls event.preventDefault() |
:stop |
Calls event.stopPropagation() |
:self |
Only fires if target is element |
:once |
Removes listener after first fire |
<a href="#" data-action="click->nav#toggle:prevent">Toggle</a>
Classes let you reference CSS classes from your controller without hardcoding them.
export default class extends Controller {
static classes = ["active", "hidden", "loading"]
toggle() {
this.element.classList.toggle(this.activeClass)
}
load() {
if (this.hasLoadingClass) {
this.element.classList.add(this.loadingClass)
}
}
}
<div data-controller="toggle"
data-toggle-active-class="bg-blue-500 text-white"
data-toggle-hidden-class="hidden">
</div>
export default class extends Controller {
initialize() {
// Called once when controller is first instantiated
// Use for one-time setup that doesn't depend on DOM
}
connect() {
// Called each time controller connects to DOM
// Set up event listeners, fetch data, start timers
}
disconnect() {
// Called when controller disconnects from DOM
// ALWAYS clean up: remove listeners, clear timers, abort fetches
}
}
export default class extends Controller {
connect() {
this.interval = setInterval(() => this.refresh(), 5000)
this.abortController = new AbortController()
}
disconnect() {
clearInterval(this.interval)
this.abortController.abort()
}
async refresh() {
const response = await fetch(this.urlValue, {
signal: this.abortController.signal,
})
// ...
}
}
// toggle_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["content"]
static classes = ["hidden"]
toggle() {
this.contentTarget.classList.toggle(this.hiddenClass)
}
show() {
this.contentTarget.classList.remove(this.hiddenClass)
}
hide() {
this.contentTarget.classList.add(this.hiddenClass)
}
}
// clipboard_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["source"]
static values = { successDuration: { type: Number, default: 2000 } }
copy() {
navigator.clipboard.writeText(this.sourceTarget.value)
this.showCopiedState()
}
showCopiedState() {
this.element.dataset.copied = true
setTimeout(() => delete this.element.dataset.copied, this.successDurationValue)
}
}
// search_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "form"]
static values = { delay: { type: Number, default: 300 } }
search() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.formTarget.requestSubmit()
}, this.delayValue)
}
disconnect() {
clearTimeout(this.timeout)
}
}
Turbo Drive preserves <head> but replaces <body>. Controllers on body elements disconnect and reconnect. Use values to persist state:
<!-- State survives Turbo navigation because it's in HTML -->
<div data-controller="sidebar" data-sidebar-open-value="true">
export default class extends Controller {
connect() {
document.addEventListener("turbo:before-cache", this.cleanup)
}
disconnect() {
document.removeEventListener("turbo:before-cache", this.cleanup)
}
cleanup = () => {
// Reset state before Turbo caches the page
this.element.classList.remove("is-active")
}
}
export default class extends Controller {
connect() {
this.element.addEventListener("turbo:frame-load", this.onFrameLoad)
}
onFrameLoad = (event) => {
// React to frame content loading
this.updateUI()
}
}
disconnect()toggle, submit, validate not handleClick, onClick| Don't | Do Instead |
|---|---|
| Store state in instance variables | Use values (static values = {}) |
Use querySelector in controllers |
Use targets (static targets = []) |
| Hardcode CSS classes | Use classes (static classes = []) |
Forget to clean up in disconnect |
Always clean up timers, listeners, etc. |
| Make controllers too large | Split into multiple focused controllers |
| Use Stimulus for data fetching | Use Turbo Frames/Streams for server data |
| Duplicate controller logic | Extract shared behavior to base class |
