Stimulus Coder
Audience: Developers building interactive UIs with Stimulus.js and Hotwire.
Goal: Write maintainable Stimulus controllers where state lives in HTML and controllers add behavior.
Core Concepts
-
Controllers attach behavior to HTML elements
-
Actions respond to DOM events
-
Targets reference important elements
-
Values manage state through data attributes
Controller Design Principles
Keep Controllers Small and Reusable
// Good: Generic, reusable controller import { Controller } from "@hotwired/stimulus"
export default class extends Controller { static targets = ["content"] static values = { open: Boolean }
toggle() { this.openValue = !this.openValue }
openValueChanged() { this.contentTarget.classList.toggle("hidden", !this.openValue) } }
Use Data Attributes for Configuration
export default class extends Controller { static values = { delay: { type: Number, default: 300 }, event: { type: String, default: "input" } }
connect() { this.element.addEventListener(this.eventValue, this.submit.bind(this)) }
submit() { clearTimeout(this.timeout) this.timeout = setTimeout(() => this.element.requestSubmit(), this.delayValue) } }
<%= form_with data: { controller: "auto-submit", auto_submit_delay_value: 500 } %>
Compose Multiple Controllers
<div data-controller="toggle clipboard" data-toggle-open-value="false"> <button data-action="toggle#toggle">Show</button> <div data-toggle-target="content" class="hidden"> <code data-clipboard-target="source">secret-code</code> <button data-action="clipboard#copy">Copy</button> </div> </div>
Targets and Values
Targets for Element References
export default class extends Controller { static targets = ["tab", "panel"] static values = { index: { type: Number, default: 0 } }
select(event) { this.indexValue = this.tabTargets.indexOf(event.currentTarget) }
indexValueChanged() { this.panelTargets.forEach((panel, i) => panel.classList.toggle("hidden", i !== this.indexValue)) this.tabTargets.forEach((tab, i) => tab.setAttribute("aria-selected", i === this.indexValue)) } }
Action Handling
<button data-action="click->toggle#toggle">Toggle</button> <input data-action="input->search#update focus->search#expand"> <button data-action="modal#open" data-modal-id-param="confirm-dialog">Open</button> <input data-action="keydown.enter->form#submit keydown.escape->form#cancel">
Action Parameters
open(event) { const modalId = event.params.id document.getElementById(modalId)?.showModal() }
Common Controller Patterns
Dropdown Controller
export default class extends Controller { static targets = ["menu"] static values = { open: Boolean }
toggle() { this.openValue = !this.openValue }
close(event) { if (!this.element.contains(event.target)) this.openValue = false }
openValueChanged() { this.menuTarget.classList.toggle("hidden", !this.openValue) if (this.openValue) document.addEventListener("click", this.close.bind(this), { once: true }) } }
Clipboard Controller
export default class extends Controller { static targets = ["source", "button"] static values = { successMessage: { type: String, default: "Copied!" } }
async copy() { const text = this.sourceTarget.value || this.sourceTarget.textContent await navigator.clipboard.writeText(text) this.showSuccess() }
showSuccess() { const original = this.buttonTarget.textContent this.buttonTarget.textContent = this.successMessageValue setTimeout(() => this.buttonTarget.textContent = original, 2000) } }
Turbo Integration
export default class extends Controller { connect() { document.addEventListener("turbo:before-visit", this.dismiss.bind(this)) this.timeout = setTimeout(() => this.dismiss(), 5000) }
disconnect() { clearTimeout(this.timeout) } dismiss() { this.element.remove() } }
Architecture Patterns
Make Controllers Configurable
Externalize hardcoded values into data attributes. Never embed CSS classes, selectors, or thresholds in controller logic.
// Bad: hardcoded export default class extends Controller { toggle() { this.element.classList.toggle("hidden") } }
// Good: configurable export default class extends Controller { static classes = ["toggle"] toggle() { this.element.classList.toggle(this.toggleClass) } }
Mixins Over Deep Inheritance
Use mixins when behavior is shared but doesn't represent specialization.
Decision framework:
-
"is a" → inheritance (class extends BaseController)
-
"acts as" → mixin (apply behavior at connect)
-
"has a" → composition (separate controller + outlets)
// Mixin pattern const Sortable = (controller) => { const original = controller.prototype.connect controller.prototype.connect = function() { if (original) original.call(this) this.sortable = new Sortable(this.element, this.sortableOptions) } }
Targetless Controllers
If a controller mixes element-level and target-level concerns, split it. Controller acting on this.element is one responsibility; acting on targets is another.
Communicate between split controllers via custom events or outlets.
Namespaced Attributes
For flexible parameter sets without explicitly defining each value:
// Read arbitrary data-chart-* attributes get chartOptions() { return Object.entries(this.element.dataset) .filter(([key]) => key.startsWith("chart")) .reduce((opts, [key, val]) => { opts[key.replace("chart", "").toLowerCase()] = val return opts }, {}) }
See architecture-patterns.md for SOLID principles applied to Stimulus.
Controller Communication
Choose pattern based on coupling needs:
Pattern Coupling Direction Use When
Custom events Loose Broadcast (1→many) Sender doesn't know receivers
Outlets Structured Direct (1→1, 1→few) Known relationships in layout
Callbacks Read-only Request/response Sharing state without triggering actions
Custom Events (Preferred Default)
// Sender this.dispatch("submitted", { detail: { id: this.idValue }, bubbles: true })
// Receiver (in HTML) // data-action="sender:submitted->receiver#handleSubmit"
Rules:
-
Always set bubbles: true for cross-controller events
-
Namespace event names: form:submitted , cart:updated
-
Document the detail contract
Outlets (Structured Relationships)
export default class extends Controller { static outlets = ["result"]
search() { const results = this.performSearch() this.resultOutlets.forEach(outlet => outlet.update(results)) }
resultOutletConnected(outlet) { /* setup / } resultOutletDisconnected(outlet) { / cleanup */ } }
Lifecycle Best Practices
Don't Overuse connect()
connect() is for third-party plugin initialization only. Not for state setup (use Values API) or event listeners (use data-action ).
// Good: plugin init in connect connect() { this.chart = new Chart(this.canvasTarget, this.chartConfig) }
disconnect() { this.chart.destroy() this.chart = null }
Always Pair connect/disconnect
Every resource acquired in connect() must be released in disconnect() . Controllers can connect/disconnect multiple times during Turbo navigation.
Turbo Cache Teardown
Prevent "flash of manipulated content" when cached pages return:
connect() { document.addEventListener("turbo:before-cache", this.teardown.bind(this)) this.slider = new Swiper(this.element, this.config) }
teardown() { this.slider?.destroy() // Restore original DOM state before caching }
disconnect() { this.teardown() }
Event Listener Hygiene
Store Bound References
.bind() creates a new function each call. Store the reference for proper removal:
connect() { this.boundResize = this.resize.bind(this) window.addEventListener("resize", this.boundResize, { passive: true }) }
disconnect() { window.removeEventListener("resize", this.boundResize) }
Prefer Declarative Actions
<%# Good: Stimulus manages lifecycle %> <div data-controller="search" data-action="resize@window->search#layout keydown.escape@window->search#close">
<%# Bad: manual addEventListener in connect() %>
Global events use @window or @document suffix in data-action .
See lifecycle-and-events.md for complete patterns.
Application Controller
Create app/javascript/controllers/application_controller.js as a base for shared functionality:
import { Controller } from "@hotwired/stimulus"
export default class ApplicationController extends Controller {
handleError(error, context = {}) {
console.error([${this.identifier}], error, context)
// Sentry.captureException(error, { extra: context })
}
}
Extend it in domain controllers:
import ApplicationController from "./application_controller"
export default class extends ApplicationController { async save() { try { await this.persist() } catch (error) { this.handleError(error, { action: "save", id: this.idValue }) } } }
Rules:
-
Use try-catch for async operations and third-party library calls
-
Never swallow errors — log or report via handleError()
-
Use requestSubmit() not submit() for forms — fires validation and Turbo intercept
Anti-Patterns
Anti-Pattern Problem Solution
Creating DOM extensively Fighting Stimulus philosophy Let server render HTML
Storing state in JS State lost on navigation Use Values in HTML
Over-specific controllers Not reusable Design generic behaviors
Manual querySelector Fragile, bypasses Stimulus Use targets
Inline event handlers Unmaintainable Use data-action
Overloading connect() Bloated, mixes concerns Values for state, data-action for events
Tight controller coupling Fragile, hard to test Custom events or outlets
Missing disconnect cleanup Memory leaks, duplicate listeners Always pair connect/disconnect
Unbound event references Can't removeEventListener Store .bind() result
Output Format
When creating Stimulus controllers, provide:
-
Controller - Complete JavaScript implementation
-
HTML Example - Sample markup showing usage
-
Configuration - Available values and targets
-
Integration - How it works with Turbo if applicable