Rails Stimulus Expert
Build small, focused JavaScript controllers that connect HTML to behavior through data attributes.
Philosophy
Core Principles:
- HTML-first — Stimulus enhances server-rendered HTML, it doesn't replace it
- Small controllers — One controller = one behavior. Compose by stacking controllers on elements
- Progressive enhancement — Pages must work without JavaScript; controllers add interactivity
- No rendering in JS — Controllers manipulate DOM state (classes, attributes, visibility), never build HTML strings
- Convention over configuration — Data attributes wire everything; no manual event binding
The Stimulus Mental Model:
HTML (data attributes) → Controller (JS behavior) → DOM changes (classes, text, visibility)
↑ source of truth ↑ small & focused ↑ CSS does the heavy lifting
When To Use This Skill
- Creating new Stimulus controllers
- Connecting controllers to HTML via data attributes
- Adding interactivity to server-rendered views (toggles, modals, clipboard, flash, forms)
- Debugging controller connection issues
- Organizing controller files and imports
- Using values, targets, classes, outlets, and lifecycle callbacks
- Cross-controller communication via outlets or custom events
Instructions
Step 1: Check Existing Controllers
ALWAYS search for existing controllers before creating new ones:
# List all controllers
ls app/javascript/controllers/
# Search for similar behavior
rg "static targets" app/javascript/controllers/
rg "static values" app/javascript/controllers/
# Check if there's a matching controller already
rg "data-controller=\"toggle\"" app/views/
Match existing project conventions — naming, style, patterns. Consistency beats "ideal."
Step 2: Generate or Create the Controller
Use the Rails generator:
bin/rails generate stimulus example
# Creates: app/javascript/controllers/example_controller.js
# Updates: app/javascript/controllers/index.js (if not using auto-loading)
Or create manually:
// app/javascript/controllers/example_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
}
}
Controllers in app/javascript/controllers/ are auto-registered via index.js:
// app/javascript/controllers/index.js
import { application } from "./application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
Step 3: Define the Controller Interface
Declare targets, values, classes, and outlets statically at the top:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "output", "submit"]
static values = {
url: String,
count: { type: Number, default: 0 },
enabled: Boolean,
items: Array,
config: Object
}
static classes = ["active", "loading", "hidden"]
static outlets = ["other-controller"]
// Lifecycle, then actions
connect() { }
disconnect() { }
// Action methods
toggle() { }
submit() { }
}
Order convention: static declarations → lifecycle → actions → private helpers.
Step 4: Wire Up HTML with Data Attributes
⚠️ CRITICAL: Data attribute naming is the #1 source of bugs.
The rules:
- Controller names: kebab-case in HTML (
data-controller="my-thing"), snake_case filenames (my_thing_controller.js), camelCase never appears in HTML - Multi-word values: kebab-case in HTML attributes, camelCase in JavaScript access
- Target attribute format:
data-{controller}-target="{name}" - Value attribute format:
data-{controller}-{name}-value="{val}" - Class attribute format:
data-{controller}-{class}-class="{css-class}" - Action format:
data-action="{event}->{controller}#{method}"
<%# Controller with values, targets, and actions %>
<div data-controller="search"
data-search-url-value="<%= search_path %>"
data-search-debounce-value="300"
data-search-active-class="is-active">
<input data-search-target="input"
data-action="input->search#query keydown.escape->search#clear"
type="text"
placeholder="Search...">
<div data-search-target="results"></div>
</div>
Common naming mistakes agents make:
<%# WRONG — camelCase in HTML attribute %>
<div data-controller="myThing">
<div data-myThing-url-value="/api">
<%# CORRECT — kebab-case in HTML %>
<div data-controller="my-thing">
<div data-my-thing-url-value="/api">
<%# WRONG — wrong target format %>
<div data-target="search.input">
<%# CORRECT — namespaced target format %>
<div data-search-target="input">
Step 5: Handle Actions Correctly
Default events (can omit event name):
| Element | Default Event |
|---------|--------------|
| <button> | click |
| <input> | input |
| <select> | change |
| <form> | submit |
| <a> | click |
| <textarea> | input |
| <details> | toggle |
<%# These are equivalent for a button: %>
<button data-action="click->toggle#flip">Toggle</button>
<button data-action="toggle#flip">Toggle</button>
<%# Multiple actions on one element: %>
<input data-action="input->search#query focus->search#expand blur->search#collapse">
<%# Keyboard modifiers: %>
<input data-action="keydown.enter->form#submit keydown.escape->form#cancel">
<%# Event options: %>
<a data-action="click->nav#toggle:prevent">Link</a>
<button data-action="click->menu#close:stop">Close</button>
<div data-action="scroll->lazy#load:once">Load once</div>
Available key modifiers: enter, tab, esc, space, up, down, left, right, home, end, plus any KeyboardEvent.key value.
Action options: :prevent (preventDefault), :stop (stopPropagation), :once (remove after first call), :self (only if event.target is the element itself).
Step 6: Use Lifecycle Callbacks
export default class extends Controller {
// Called once when controller class is first instantiated
// Use for: one-time setup like binding methods for callbacks
initialize() {
this.search = this.search.bind(this)
}
// Called every time the controller's element enters the DOM
// Use for: setting up timers, observers, fetching initial data
connect() {
this.interval = setInterval(() => this.poll(), 5000)
}
// Called every time the controller's element leaves the DOM
// Use for: cleanup! Timers, observers, event listeners
disconnect() {
clearInterval(this.interval)
}
// Target connected/disconnected callbacks
outputTargetConnected(element) {
// Called when a new output target appears in DOM
}
outputTargetDisconnected(element) {
// Called when an output target is removed from DOM
}
// Value change callbacks
countValueChanged(newValue, oldValue) {
this.outputTarget.textContent = newValue
}
}
⚠️ Always clean up in disconnect(). Stimulus controllers connect/disconnect as DOM changes (Turbo navigation, Turbo Streams, etc.). Leaked timers and observers are the most common Stimulus bug.
Step 7: Keep Controllers Small
One behavior per controller. Compose by stacking.
<%# Good — two focused controllers %>
<div data-controller="dropdown tooltip">
<button data-action="click->dropdown#toggle mouseenter->tooltip#show mouseleave->tooltip#hide">
Options
</button>
</div>
<%# Bad — one mega-controller doing everything %>
<div data-controller="dropdown-with-tooltip-and-keyboard-nav">
If a controller exceeds ~80 lines, it's probably doing too much. Split it.
Step 8: Use CSS for Visual State
Controllers toggle classes. CSS does the rendering.
// Good — controller manages state
toggle() {
this.element.classList.toggle(this.activeClass)
}
// Bad — controller manages appearance
toggle() {
this.element.style.display = this.element.style.display === "none" ? "block" : "none"
this.element.style.opacity = "1"
this.element.style.transform = "translateY(0)"
}
/* CSS handles all visual transitions */
.dropdown { display: none; }
.dropdown.is-active { display: block; }
Step 9: Use Outlets for Cross-Controller Communication
Outlets let one controller reference and call methods on another:
// tabs_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static outlets = ["panel"]
select(event) {
const index = event.currentTarget.dataset.index
this.panelOutlets.forEach((panel, i) => {
panel.toggle(i === parseInt(index))
})
}
}
<div data-controller="tabs" data-tabs-panel-outlet=".tab-panel">
<button data-action="click->tabs#select" data-index="0">Tab 1</button>
<button data-action="click->tabs#select" data-index="1">Tab 2</button>
<div class="tab-panel" data-controller="panel">Content 1</div>
<div class="tab-panel" data-controller="panel">Content 2</div>
</div>
Outlet naming: kebab-case controller name in data-{controller}-{outlet-name}-outlet attribute. The outlet value is a CSS selector that matches elements with the target controller.
Alternative: Custom Events — for looser coupling when outlets feel too tight:
// Dispatching controller
this.dispatch("selected", { detail: { index: 0 } })
// Listening in HTML
<div data-action="tabs:selected->panel#activate">
Step 10: Debugging
// Enable debug mode in browser console
Stimulus.debug = true
// Shows: connect/disconnect events, action dispatches, value changes
// Add logging in connect() for troubleshooting
connect() {
console.log(`${this.identifier} connected`, this.element)
console.log("targets:", this.outputTargets)
console.log("values:", this.urlValue, this.countValue)
}
Common issues:
- Controller not connecting → Check: typo in
data-controller, file naming (snake_case_controller.js), controller registered inindex.js - Target not found → Check: target element is inside the controller's element, correct
data-{controller}-targetformat - Action not firing → Check:
data-actionformat isevent->controller#method, method exists and isn't a typo - Values not updating → Check:
data-{controller}-{name}-valueformat, value type matches static declaration - Controller disconnects unexpectedly → Turbo navigation replaced the DOM. Make sure controller element persists or re-attaches properly.
Quick Reference
Accessing Targets
this.outputTarget // First matching target (throws if missing)
this.outputTargets // Array of all matching targets
this.hasOutputTarget // Boolean — does at least one exist?
Accessing Values
this.urlValue // Get
this.urlValue = "/new" // Set (triggers valueChanged callback)
this.hasUrlValue // Boolean — was it specified in HTML?
Accessing Classes
this.activeClass // Single class string, e.g. "is-active"
this.activeClasses // Array of classes
this.hasActiveClass // Boolean
Accessing Outlets
this.panelOutlet // First matching outlet controller
this.panelOutlets // Array of all matching outlet controllers
this.hasPanelOutlet // Boolean
this.panelOutletElement // The DOM element of the first outlet
this.panelOutletElements // Array of DOM elements
Value Types
| Type | HTML Example | JS Default |
|------|-------------|------------|
| String | data-x-name-value="hello" | "" |
| Number | data-x-count-value="5" | 0 |
| Boolean | data-x-open-value="true" | false |
| Array | data-x-items-value='["a","b"]' | [] |
| Object | data-x-config-value='{"k":"v"}' | {} |
File Organization
app/javascript/
├── application.js # Entry point, imports controllers
├── controllers/
│ ├── application.js # Base controller (extend this)
│ ├── index.js # Auto-loader registration
│ ├── clipboard_controller.js # Simple, focused controllers
│ ├── dropdown_controller.js
│ ├── flash_controller.js
│ ├── modal_controller.js
│ ├── toggle_controller.js
│ └── form_validation_controller.js
Naming: {behavior}_controller.js — name by what it does, not what it's for.
- ✅
toggle_controller.js,clipboard_controller.js,auto_submit_controller.js - ❌
sidebar_controller.js,header_controller.js,user_form_controller.js
Common Patterns
See reference.md for complete implementations of:
- Clipboard copy with visual feedback
- Auto-dismissing flash messages
- Modal dialogs (with
<dialog>) - Toggle/disclosure
- Form validation
- Debounced search
- Character counter
- Auto-submit forms
- Nested/namespaced controllers
Anti-Patterns to Avoid
- Mega-controllers — If it's > 80 lines, split it into composable pieces
- Rendering HTML in JS — Use Turbo Streams for dynamic content; Stimulus just toggles state
- Direct style manipulation — Toggle classes, let CSS handle appearance
- Forgetting disconnect cleanup — Every
setInterval,addEventListener,MutationObserverinconnect()needs cleanup indisconnect() - camelCase in HTML attributes — Always kebab-case:
data-my-thing-url-value, notdata-myThing-url-value - Reaching outside the controller element — Use outlets or events for cross-controller communication, don't
document.querySelectorfrom inside a controller - Business logic in controllers — Keep controllers thin; complex logic belongs on the server
- Not using values for configuration — Don't hardcode URLs, durations, or thresholds; use values so HTML can configure behavior