Help us improve
Share bugs, ideas, or general feedback.
From majestic-rails
Creates and refactors Stimulus controllers using Hotwire conventions, design patterns, targets/values, action handling, and JavaScript best practices for interactive UIs.
npx claudepluginhub majesticlabs-dev/majestic-marketplace --plugin majestic-railsHow this skill is triggered — by the user, by Claude, or both
Slash command
/majestic-rails:stimulus-coderThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
**Audience:** Developers building interactive UIs with Stimulus.js and Hotwire.
Covers Stimulus controller fundamentals: lifecycle hooks, values, targets, outlets, action parameters, keyboard events, and architecture patterns. Best for Stimulus API questions outside Hotwire domains.
Builds Stimulus JS controllers for Symfony UX to handle client-side DOM manipulation, events, targets, values, outlets, and UI like toggles, modals, dropdowns via HTML data attributes.
Implements Hotwire Turbo (Drive, Frames, Streams, Morph) and Stimulus controllers in Rails views for SPA-like interactivity, real-time updates, and progressive enhancement.
Share bugs, ideas, or general feedback.
Audience: Developers building interactive UIs with Stimulus.js and Hotwire.
Goal: Write maintainable Stimulus controllers where state lives in HTML and controllers add behavior.
// 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)
}
}
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 } %>
<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>
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))
}
}
<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">
open(event) {
const modalId = event.params.id
document.getElementById(modalId)?.showModal()
}
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 })
}
}
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)
}
}
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() }
}
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) }
}
Use mixins when behavior is shared but doesn't represent specialization.
Decision framework:
// 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)
}
}
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.
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.
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 |
// Sender
this.dispatch("submitted", { detail: { id: this.idValue }, bubbles: true })
// Receiver (in HTML)
// data-action="sender:submitted->receiver#handleSubmit"
Rules:
bubbles: true for cross-controller eventsform:submitted, cart:updateddetail contractexport 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 */ }
}
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
}
Every resource acquired in connect() must be released in disconnect(). Controllers can connect/disconnect multiple times during Turbo navigation.
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()
}
.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)
}
<%# 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.
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:
try-catch for async operations and third-party library callshandleError()requestSubmit() not submit() for forms — fires validation and Turbo intercept| 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 |
When creating Stimulus controllers, provide: