Stimulus
Stimulus.js controller patterns for adding interactive behavior to server-rendered Rails HTML.
You are an expert in Stimulus.js for adding lightweight, progressive JavaScript behavior to Rails applications.
## Key Points
- Keep controllers small and single-purpose. Prefer composing multiple controllers on an element over building monolithic ones.
- Use `values` for configuration passed from the server. Avoid hardcoding URLs or text in JavaScript.
- Use `classes` for CSS class names so the controller is not coupled to a specific design system.
- Clean up timers, observers, and event listeners in `disconnect()`.
- Name controllers by behavior (e.g., `toggle`, `clipboard`, `auto-submit`), not by the UI element they control.
- Prefer `data-action` attributes in HTML over `addEventListener` in `connect()`.
- Combine Stimulus with Turbo Frames and Streams; let the server handle data while Stimulus handles UI interactions.
- **Forgetting to register controllers**: With importmap or esbuild auto-loading, ensure the file is named correctly (e.g., `hello_controller.js` maps to `data-controller="hello"`).
- **Target not found errors**: If a target may not exist, check `this.hasXxxTarget` before accessing it.
- **Memory leaks**: Not cleaning up intervals or observers in `disconnect()` causes leaks, especially with Turbo navigation.
- **Overusing Stimulus**: If you need complex client-side state management, Stimulus may not be the right tool. Consider whether Turbo Streams can handle the update from the server instead.
- **Mixing Stimulus with inline scripts**: Avoid `<script>` tags in partials that Turbo swaps. Use Stimulus controllers instead.
## Quick Example
```erb
<div data-controller="greeting" data-greeting-greeting-value="Hi">
<input data-greeting-target="name" type="text" placeholder="Your name">
<button data-action="click->greeting#greet">Greet</button>
<p data-greeting-target="output"></p>
</div>
```
```erb
<div data-controller="poller"
data-poller-url-value="<%= notifications_path %>"
data-poller-refresh-interval-value="3000"
data-poller-active-value="true">
</div>
```skilldb get ruby-rails-skills/StimulusFull skill: 329 linesStimulus Controllers — Ruby on Rails
You are an expert in Stimulus.js for adding lightweight, progressive JavaScript behavior to Rails applications.
Overview
Stimulus is a modest JavaScript framework that connects behavior to server-rendered HTML using data attributes. It complements Turbo by handling interactions that require client-side logic -- toggling visibility, form validation, copy-to-clipboard, and similar patterns -- without building a full SPA.
Core Philosophy
Stimulus exists because not every interaction needs a server round-trip, but most interactions do not need a JavaScript framework either. It fills the gap between static HTML and full single-page applications by providing just enough structure to attach behavior to server-rendered markup. The HTML is the source of truth; Stimulus simply makes it interactive.
The design principle behind Stimulus is that behavior should be declared in the HTML, not hidden in JavaScript files. Data attributes like data-controller, data-action, and data-target make it visible in the template exactly which JavaScript behaviors are attached to which elements. This means a developer reading the HTML can understand the page's interactivity without context-switching to a separate codebase.
Controllers should be small, reusable, and composable. A toggle controller, a clipboard controller, and a debounce controller can each be written once and composed on any element. The moment a Stimulus controller starts managing complex state, fetching data, or coordinating multiple API calls, it has outgrown Stimulus and should either be handled server-side with Turbo or moved to a dedicated JavaScript framework.
Anti-Patterns
-
Monolithic Controllers: Building a single controller with dozens of targets, values, and methods that manages an entire page section. Stimulus controllers should be small and single-purpose. If a controller name is something like
dashboard_controller.js, it is probably doing too much. -
Reimplementing Turbo in Stimulus: Writing Stimulus controllers that fetch HTML from the server and swap DOM content manually. This is exactly what Turbo Frames and Streams are designed for. Use Turbo for server-driven updates and reserve Stimulus for client-only interactions.
-
Hardcoded Values in JavaScript: Embedding URLs, API keys, CSS class names, or translatable strings directly in controller code instead of passing them as
valuesandclassesfrom the HTML. This couples your JavaScript to your application's routing and design system. -
Event Listener Leaks: Adding event listeners in
connect()without removing them indisconnect(). Because Turbo navigations mount and unmount controllers frequently, leaked listeners accumulate and cause performance degradation or duplicate event handling. -
Bypassing the Action System: Using
addEventListenerinside controllers instead of declaring actions withdata-actionattributes in the HTML. This hides behavior from anyone reading the markup and loses the declarative clarity that is Stimulus's primary advantage.
Core Concepts
Controller Lifecycle
// app/javascript/controllers/greeting_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["name", "output"]
static values = { greeting: { type: String, default: "Hello" } }
static classes = ["active"]
connect() {
// Called when the controller's element appears in the DOM
console.log("Greeting controller connected")
}
disconnect() {
// Called when the controller's element is removed from the DOM
}
greet() {
this.outputTarget.textContent =
`${this.greetingValue}, ${this.nameTarget.value}!`
}
}
<div data-controller="greeting" data-greeting-greeting-value="Hi">
<input data-greeting-target="name" type="text" placeholder="Your name">
<button data-action="click->greeting#greet">Greet</button>
<p data-greeting-target="output"></p>
</div>
Targets
Targets provide typed references to important DOM elements within the controller scope.
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["item", "count"]
// Generated methods:
// this.itemTarget - first matching element (throws if missing)
// this.itemTargets - array of all matching elements
// this.hasItemTarget - boolean check
updateCount() {
this.countTarget.textContent = this.itemTargets.length
}
}
Values
Values are typed reactive properties read from data attributes.
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
url: String,
refreshInterval: { type: Number, default: 5000 },
active: Boolean
}
// Auto-generated: this.urlValue, this.refreshIntervalValue, this.activeValue
// Change callbacks are called when the data attribute changes:
urlValueChanged() {
this.fetchData()
}
activeValueChanged() {
this.element.classList.toggle("is-active", this.activeValue)
}
}
<div data-controller="poller"
data-poller-url-value="<%= notifications_path %>"
data-poller-refresh-interval-value="3000"
data-poller-active-value="true">
</div>
Actions
Actions connect DOM events to controller methods.
<%# Standard click action %>
<button data-action="click->modal#open">Open</button>
<%# Multiple actions on one element %>
<input data-action="input->search#query focus->search#expand blur->search#collapse">
<%# Keyboard events %>
<input data-action="keydown.enter->form#submit keydown.escape->form#cancel">
<%# Form events %>
<form data-action="submit->form#validate">
<%# Window and document events %>
<div data-controller="nav" data-action="scroll@window->nav#highlight">
</div>
Implementation Patterns
Toggle Controller
// app/javascript/controllers/toggle_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["content"]
static classes = ["hidden"]
toggle() {
this.contentTargets.forEach(target => {
target.classList.toggle(this.hiddenClass)
})
}
show() {
this.contentTargets.forEach(t => t.classList.remove(this.hiddenClass))
}
hide() {
this.contentTargets.forEach(t => t.classList.add(this.hiddenClass))
}
}
<div data-controller="toggle" data-toggle-hidden-class="hidden">
<button data-action="toggle#toggle">Toggle Details</button>
<div data-toggle-target="content" class="hidden">
<p>Detailed information here.</p>
</div>
</div>
Debounced Search
// app/javascript/controllers/search_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "results"]
static values = { url: String, delay: { type: Number, default: 300 } }
search() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.fetchResults()
}, this.delayValue)
}
async fetchResults() {
const query = this.inputTarget.value.trim()
if (query.length < 2) return
const url = `${this.urlValue}?q=${encodeURIComponent(query)}`
const response = await fetch(url, {
headers: { "Accept": "text/vnd.turbo-stream.html" }
})
if (response.ok) {
const html = await response.text()
this.resultsTarget.innerHTML = html
}
}
disconnect() {
clearTimeout(this.timeout)
}
}
Clipboard Controller
// app/javascript/controllers/clipboard_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["source", "button"]
static values = { successDuration: { type: Number, default: 2000 } }
async copy() {
const text = this.sourceTarget.value || this.sourceTarget.textContent
await navigator.clipboard.writeText(text.trim())
const original = this.buttonTarget.textContent
this.buttonTarget.textContent = "Copied!"
setTimeout(() => {
this.buttonTarget.textContent = original
}, this.successDurationValue)
}
}
Form Auto-Submit
// app/javascript/controllers/auto_submit_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { delay: { type: Number, default: 500 } }
submit() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.element.requestSubmit()
}, this.delayValue)
}
disconnect() {
clearTimeout(this.timeout)
}
}
<%= form_with url: search_path, method: :get,
data: { controller: "auto-submit", turbo_frame: "results" } do |f| %>
<%= f.select :category, options,
data: { action: "change->auto-submit#submit" } %>
<%= f.search_field :q,
data: { action: "input->auto-submit#submit" } %>
<% end %>
Controller Communication with Events
// app/javascript/controllers/cart_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["count", "total"]
connect() {
this.element.addEventListener("cart:updated", this.refresh.bind(this))
}
refresh(event) {
this.countTarget.textContent = event.detail.itemCount
this.totalTarget.textContent = event.detail.total
}
}
// app/javascript/controllers/add_to_cart_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
add() {
// After adding item...
this.dispatch("updated", {
detail: { itemCount: 5, total: "$49.99" },
prefix: "cart",
target: document.querySelector("[data-controller='cart']")
})
}
}
Best Practices
- Keep controllers small and single-purpose. Prefer composing multiple controllers on an element over building monolithic ones.
- Use
valuesfor configuration passed from the server. Avoid hardcoding URLs or text in JavaScript. - Use
classesfor CSS class names so the controller is not coupled to a specific design system. - Clean up timers, observers, and event listeners in
disconnect(). - Name controllers by behavior (e.g.,
toggle,clipboard,auto-submit), not by the UI element they control. - Prefer
data-actionattributes in HTML overaddEventListenerinconnect(). - Combine Stimulus with Turbo Frames and Streams; let the server handle data while Stimulus handles UI interactions.
Common Pitfalls
- Forgetting to register controllers: With importmap or esbuild auto-loading, ensure the file is named correctly (e.g.,
hello_controller.jsmaps todata-controller="hello"). - Target not found errors: If a target may not exist, check
this.hasXxxTargetbefore accessing it. - Memory leaks: Not cleaning up intervals or observers in
disconnect()causes leaks, especially with Turbo navigation. - Overusing Stimulus: If you need complex client-side state management, Stimulus may not be the right tool. Consider whether Turbo Streams can handle the update from the server instead.
- Mixing Stimulus with inline scripts: Avoid
<script>tags in partials that Turbo swaps. Use Stimulus controllers instead.
Install this skill directly: skilldb add ruby-rails-skills
Related Skills
Active Record
ActiveRecord query patterns, associations, validations, callbacks, and performance optimization for Rails applications.
API Mode
Building JSON APIs with Rails API mode, serialization, versioning, authentication, and rate limiting.
Concerns Modules
ActiveSupport::Concern patterns, module design, and code organization strategies for maintainable Rails applications.
Deployment
Deploying Rails applications with Kamal, Docker, and production best practices for infrastructure and operations.
Hotwire Turbo
Hotwire and Turbo Drive, Frames, and Streams for building reactive Rails frontends without heavy JavaScript.
Sidekiq
Background job processing with Sidekiq, including job design, error handling, queues, and performance tuning in Rails.