stimulus
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
More from thinkoodle/rails-skills
minitest
Expert guidance for writing fast, maintainable Minitest tests in Rails applications. Use when writing tests, converting from RSpec, debugging test failures, improving test performance, or following testing best practices. Covers model tests, policy tests, request tests, system tests, fixtures, and TDD workflows.
32caching
Expert guidance for Rails caching — fragment caching, Russian doll caching, cache keys/versioning, low-level caching (Rails.cache), conditional GET (stale?/fresh_when), and cache stores (Solid Cache, Redis, Memcached). Use when implementing cache, caching, fragment cache, Russian doll, Rails.cache, Solid Cache, cache key, HTTP caching, stale?, fresh_when, cache store, or optimizing performance.
4uuid-primary-keys
Expert guidance for implementing UUID primary keys in Rails applications. Use when setting up UUIDs as primary keys, choosing between UUIDv4 and UUIDv7, configuring generators for UUID defaults, writing migrations with id colon uuid, adding UUID foreign keys, implementing base36 encoding for URL-friendly IDs, configuring PostgreSQL pgcrypto or gen_random_uuid, implementing SQLite binary UUID storage, choosing a primary key type, using non-sequential IDs, secure IDs, random IDs, or any ID generation strategy beyond auto-increment integers.
4security
Expert guidance for writing secure Rails applications. Use when dealing with security, CSRF protection, XSS prevention, SQL injection, authentication, authorization, sanitize, html_safe, credentials, secrets, content security policy, session security, mass assignment, strong parameters, secure headers, file uploads, open redirects, or vulnerability remediation. Covers every major attack vector and the Rails-idiomatic defenses.
4testing
Expert guidance for Rails testing infrastructure, test types, and what to test. Use when writing tests, setting up a test suite, choosing between test types, configuring system tests (Capybara), request tests, integration tests, helper tests, mailer tests, job tests, Action Cable tests, parallel testing, CI setup, test database management, or improving test coverage. Covers the test runner, fixtures vs factories, parallel testing, system tests (drivers, screenshots), request tests, controller tests (legacy), helper tests, mailer tests, job tests, Action Cable tests, test coverage, CI patterns, and test database strategies. Trigger on "test", "testing", "test suite", "system test", "request test", "integration test", "test runner", "parallel testing", "capybara", "test database", "CI testing", "test coverage".
4i18n
Expert guidance for Rails I18n (internationalization and localization). Use when working with translations, locale files, t() / l() helpers, lazy lookups, pluralization, interpolation, date/time/number formatting, model translations, error message translations, setting locale from URL/header/session, or organizing YAML translation files. Triggers on "i18n", "internationalization", "translation", "locale", "localize", "t()", "translate", "multilingual", "pluralization", "locale file", "YAML translation".
4