Turbo handles the heavy lifting for most dynamic behavior in Rails 8 applications, but certain interactions still demand JavaScript: dropdown menus, form validation feedback, clipboard operations, and keyboard shortcuts. Stimulus provides the structure for this JavaScript, but without intentional patterns, controllers can quickly become tangled messes that are difficult to test and maintain.
This guide covers practical patterns for building Stimulus controllers that remain readable and maintainable as applications scale. The focus is on real patterns that solve real problems—not contrived examples that fall apart in production.
The Foundation: Single Responsibility Controllers
The most common mistake with Stimulus is cramming too much functionality into a single controller. A controller named form_controller.js that handles validation, submission states, auto-save, and character counting has become unmaintainable before it reaches 100 lines.
Each controller should handle one specific behavior. Multiple controllers can attach to the same element, working together without knowledge of each other:
// app/javascript/controllers/form_submit_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["submit"]
disable() {
this.submitTarget.disabled = true
this.submitTarget.textContent = "Saving..."
}
enable() {
this.submitTarget.disabled = false
this.submitTarget.textContent = "Save"
}
}
// app/javascript/controllers/character_count_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "counter"]
static values = { max: { type: Number, default: 280 } }
update() {
const remaining = this.maxValue - this.inputTarget.value.length
this.counterTarget.textContent = remaining
this.counterTarget.classList.toggle("text-red-600", remaining < 0)
}
}
The corresponding view attaches both controllers to the form:
<!-- app/views/posts/_form.html.erb -->
<%= form_with model: post,
data: {
controller: "form-submit character-count",
action: "turbo:submit-start->form-submit#disable turbo:submit-end->form-submit#enable"
} do |f| %>
<div data-character-count-target="input">
<%= f.text_area :body, data: { action: "input->character-count#update" } %>
<span data-character-count-target="counter">280</span>
</div>
<%= f.submit "Save", data: { form_submit_target: "submit" } %>
<% end %>
Values for Configuration, Not Hardcoding
Stimulus values provide a clean interface between Rails and JavaScript. Rather than hardcoding behavior, controllers should accept configuration through values that Rails can set dynamically:
// app/javascript/controllers/auto_dismiss_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
delay: { type: Number, default: 5000 },
animation: { type: String, default: "fade" }
}
connect() {
this.timeout = setTimeout(() => this.dismiss(), this.delayValue)
}
disconnect() {
clearTimeout(this.timeout)
}
dismiss() {
if (this.animationValue === "fade") {
this.element.style.transition = "opacity 0.3s"
this.element.style.opacity = "0"
setTimeout(() => this.element.remove(), 300)
} else {
this.element.remove()
}
}
}
Rails helpers can then configure behavior without touching JavaScript:
<!-- app/views/shared/_flash.html.erb -->
<% flash.each do |type, message| %>
<div data-controller="auto-dismiss"
data-auto-dismiss-delay-value="<%= type == 'error' ? 10000 : 5000 %>"
data-auto-dismiss-animation-value="fade"
class="flash flash--<%= type %>">
<%= message %>
<button data-action="auto-dismiss#dismiss">×</button>
</div>
<% end %>
Outlets for Controller Communication
When controllers need to communicate, Stimulus outlets provide a clean, explicit connection. This avoids the fragility of custom events or global state:
// app/javascript/controllers/modal_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["dialog"]
open() {
this.dialogTarget.showModal()
document.body.style.overflow = "hidden"
}
close() {
this.dialogTarget.close()
document.body.style.overflow = ""
}
closeOnBackdrop(event) {
if (event.target === this.dialogTarget) {
this.close()
}
}
}
// app/javascript/controllers/confirm_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static outlets = ["modal"]
static values = { message: String }
confirm(event) {
event.preventDefault()
if (this.hasModalOutlet) {
this.modalOutlet.open()
} else {
// Fallback for when modal isn't connected
if (window.confirm(this.messageValue)) {
event.target.form?.requestSubmit()
}
}
}
}
The view establishes the outlet connection explicitly:
<!-- app/views/posts/show.html.erb -->
<div data-controller="modal" id="delete-modal">
<dialog data-modal-target="dialog" data-action="click->modal#closeOnBackdrop">
<p>Delete this post permanently?</p>
<%= button_to "Delete", @post, method: :delete, class: "btn-danger" %>
<button data-action="modal#close">Cancel</button>
</dialog>
</div>
<div data-controller="confirm"
data-confirm-modal-outlet="#delete-modal"
data-confirm-message-value="Delete this post?">
<button data-action="confirm#confirm">Delete Post</button>
</div>
Lifecycle Methods for Cleanup
Memory leaks and orphaned event listeners plague JavaScript applications. Stimulus lifecycle methods—connect, disconnect, and the target callbacks—provide natural points for setup and teardown:
// app/javascript/controllers/keyboard_shortcut_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
key: String,
modifier: { type: String, default: "" }
}
connect() {
this.boundHandler = this.handleKeydown.bind(this)
document.addEventListener("keydown", this.boundHandler)
}
disconnect() {
document.removeEventListener("keydown", this.boundHandler)
}
handleKeydown(event) {
const modifierMatch = this.modifierValue === "" ||
(this.modifierValue === "meta" && event.metaKey) ||
(this.modifierValue === "ctrl" && event.ctrlKey)
if (modifierMatch && event.key === this.keyValue) {
event.preventDefault()
this.element.click()
}
}
}
Attaching this to any clickable element gives it a keyboard shortcut:
<!-- app/views/posts/index.html.erb -->
<%= link_to "New Post", new_post_path,
data: {
controller: "keyboard-shortcut",
keyboard_shortcut_key_value: "n",
keyboard_shortcut_modifier_value: "meta"
} %>
Common Mistakes to Avoid
Querying outside targets: Avoid this.element.querySelector when targets work. Targets are self-documenting, and Stimulus optimizes their lookup. Use queries only for dynamic content that cannot be pre-declared.
Heavy connect methods: The connect callback runs every time an element enters the DOM, including during Turbo navigation. Expensive operations should be lazy or debounced.
Ignoring Turbo cache: When Turbo caches pages, controllers disconnect and reconnect. Any state stored in class properties resets. Persist necessary state in data attributes or use data-turbo-permanent on elements that must survive navigation.
Over-abstracting: Creating a base controller class with shared functionality often creates more complexity than it solves. Prefer composition through multiple simple controllers over inheritance hierarchies.
Summary
Maintainable Stimulus controllers follow clear principles: single responsibility, configuration through values, explicit communication through outlets, and proper lifecycle management. These patterns keep JavaScript organized as Rails applications grow, ensuring that the simplicity Hotwire provides at the framework level extends into application code.
The next step is establishing testing patterns for these controllers. System tests with Capybara exercise Stimulus behavior through real browser interactions, while JavaScript unit tests with Jest or Vitest can verify complex controller logic in isolation.