From better-stimulus
Applies opinionated StimulusJS best practices for writing, reviewing, and refactoring Stimulus controllers. Covers architecture, mixins, state management with Values API, and integration with Hotwire/Turbo.
How this skill is triggered — by the user, by Claude, or both
Slash command
/better-stimulus:better-stimulusThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Opinionated StimulusJS best practices sourced directly from [betterstimulus.com](https://www.betterstimulus.com) / [julianrubisch/better-stimulus](https://github.com/julianrubisch/better-stimulus).
Opinionated StimulusJS best practices sourced directly from betterstimulus.com / julianrubisch/better-stimulus.
Create a base ApplicationController that all controllers inherit from. Use it to share lifecycle hooks and utility methods across the app.
// application_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
// shared helpers, error handling, etc.
}
// custom_controller.js
import ApplicationController from "./application_controller";
export default class extends ApplicationController {
// specialized behavior
}
When NOT to use inheritance: Ask whether the shared behavior is a specialization ("is a") → use inheritance; a role ("acts as a") → use mixins; a collaborator ("has a") → use composition.
Never hardcode dependencies (CSS classes, selectors, IDs) inside controllers. Use the Classes API, Values API, or dataset attributes so controllers are reusable.
Bad:
toggle(e) {
this.element.classList.toggle("active"); // hardcoded class
}
Good:
<a data-controller="toggle" data-action="click->toggle#toggle" data-toggle-active-class="active">
static classes = ["active"];
toggle(e) {
e.preventDefault();
this.element.classList.toggle(this.activeClass);
}
Rationale: Late binding of dependencies ensures controllers are reusable across multiple use cases without modification.
When behavior is a role (not a specialization), use mixins instead of inheritance.
Bad — extending a concrete controller:
import OverlayController from "./overlay_controller";
export default class extends OverlayController { ... }
Good — mixin pattern:
// mixins/useOverlay.js
export const useOverlay = controller => {
Object.assign(controller, {
showOverlay(e) { ... },
hideOverlay(e) { ... }
});
};
// dropdown_controller.js
import { useOverlay } from "./mixins/useOverlay";
export default class extends Controller {
connect() {
useOverlay(this);
}
}
Rule of thumb: is a → inheritance; acts as a → mixin; has a → composition. Reference: stimulus-use
Use Stimulus values as the single source of truth for controller state — not instance variables.
Bad:
connect() {
this.markers = []; // instance variable, not serialized
}
addMarker() {
this.markers.push({...});
}
Good:
static values = { markers: Array }
addMarker() {
this.markersValue = [...this.markersValue, {...}];
}
markersValueChanged(markers) {
this.map.updateMarkers(markers);
}
Rationale: Values are serialized in the DOM, providing a single source of truth. They enable state mutation from outside (Turbo Streams, morphing) and interact correctly with Turbo caching. Contraindication: Don't use values for non-serializable state (e.g., library instances like Swiper) or sensitive data you don't want in HTML.
When you need an arbitrary set of controller-scoped parameters beyond what Values API provides, namespace them as data-[controller]-param-[name].
<input data-controller="filter"
data-filter-param-category="cats"
data-filter-param-rating="5"
type="text"
data-action="input->filter#update">
update() {
const url = new URL(window.location);
Object.keys(Object.assign({}, this.element.dataset))
.filter(attr => attr.startsWith("filterParam"))
.forEach(attr => {
url.searchParams.set(
attr.slice(11).replace(/^\w/, c => c.toLowerCase()),
this.element.dataset[attr]
);
});
history.pushState({}, '', url.toString());
}
Keep controllers that act on this.element separate from those that act on targets. Mixing them is a Single Responsibility violation.
Bad — form controller managing its own indicator:
static targets = ["indicator"];
submit() {
this.indicatorTarget.textContent = "Saving...";
this.element.requestSubmit();
}
Good — split into two focused controllers:
<form data-controller="form form-indicator" data-action="submit->form-indicator#display">
<span data-form-indicator-target="indicator"></span>
<input type="number" data-action="change->form#submit" />
</form>
Signal: If a controller would change for two different reasons (element behavior AND target behavior), split it.
connect() for Plugin Init & DOM Preconditionsconnect() is the correct place for: initializing 3rd party plugins (Swiper, Dropzone, Chart.js), DOM preconditions, browser capability checks.
Keep two things out of it, using the purpose-built API instead:
data-action in the markupBad:
connect() {
this.open = false; // state in instance var
this.buttonTarget.addEventListener("click", this.toggle.bind(this)); // manual listener
}
Good:
<div data-controller="toggle"
data-toggle-open-value="false"
data-toggle-hidden-class="hidden">
<button data-action="toggle#toggle">Click to open</button>
<div data-toggle-target="panel" class="hidden"></div>
</div>
static values = { open: Boolean };
static classes = ["hidden"];
toggle() {
this.openValue = !this.openValue;
}
openValueChanged() {
this.panelTarget.classList.toggle(this.hiddenClass, !this.openValue);
}
data-actionStimulus automatically adds and removes event listeners declared in data-action — including window/document events via the @window/@document suffix. Let it manage the listener lifecycle for you.
Bad:
connect() {
document.addEventListener("resize", this.layout.bind(this));
}
Good:
<div data-controller="gallery" data-action="resize@window->gallery#layout">
If you must add listeners manually, store the bound reference to ensure proper cleanup:
Bad — .bind() creates a new function each time, so removeEventListener won't find it:
connect() {
document.addEventListener("click", this.findFoo.bind(this));
}
disconnect() {
document.removeEventListener("click", this.findFoo.bind(this)); // different reference!
}
Good:
connect() {
this.boundFindFoo = this.findFoo.bind(this);
document.addEventListener("click", this.boundFindFoo);
}
disconnect() {
document.removeEventListener("click", this.boundFindFoo);
}
Prefer custom events for broadcasting, outlets for direct method calls on other controllers, and callbacks for pulling data from another controller on demand. For the full patterns with examples, read references/inter-controller.md.
<template> to Restore DOM StateWhen an external library removes HTML from the page (e.g., after closing a Bootstrap modal), use a <template> element to restore it.
<div data-controller="modal">
<template data-modal-target="template">
<div>
<a href="#" data-action="modal#show">Click Me</a>
<div class="modal invisible" data-modal-target="modal">
<h1>A Modal</h1>
<a href="#" data-action="modal#hide">Hide Me</a>
</div>
</div>
</template>
</div>
static targets = ["template", "modal"];
connect() {
this.element.insertAdjacentHTML("beforeend", this.templateTarget.innerHTML);
}
hide(e) {
e.preventDefault();
this.element.removeChild(this.element.lastElementChild);
this.element.insertAdjacentHTML("beforeend", this.templateTarget.innerHTML);
}
Also useful: Preparing DOM for Turbo caching (restore state before turbo:before-cache).
Scope each library instance to a controller: create it in connect, tear it down in disconnect.
Bad (global array, manual DOM querying):
let editors = [];
document.addEventListener("turbo:load", function() {
document.querySelectorAll(".easymde").forEach(function(el) {
editors.push(new EasyMDE({ element: el }));
});
});
Good:
import EasyMDE from "easymde";
export default class extends Controller {
static targets = ["field"];
connect() {
this.editor = new EasyMDE({ element: this.fieldTarget });
}
disconnect() {
this.editor.toTextArea();
}
}
Benefits: Stimulus creates separate instances automatically; each can be configured independently via data attributes; Turbo lifecycle is handled automatically.
Catch all Stimulus and application errors in one place with a handleError hook on the base ApplicationController, and forward them to a reporting service (e.g. Sentry) at the application level. For the full implementation, read references/error-handling.md.
When a controller manipulates the DOM, implement a teardown() method so the page can be cleanly cached by Turbo. Trigger it globally via turbo:before-cache.
// application.js
document.addEventListener('turbo:before-cache', () => {
application.controllers.forEach(controller => {
if (typeof controller.teardown === 'function') {
controller.teardown();
}
});
});
// any_controller.js
export default class extends Controller {
connect() { /* ... */ }
teardown() {
this.element.classList.remove('play-animation');
}
}
Rationale: Keeps disconnect for controller-level teardown; teardown for Turbo-specific rollback. Prevents flash of stale/manipulated content on back navigation.
Submit forms in response to arbitrary events or intercept them for client-side logic.
Trigger submit on change:
<%= form_with(model: @article, data: { controller: "form" }) do |f| %>
<%= select_tag "author", ..., data: { action: "change->form#update" } %>
<% end %>
update(event) {
event.preventDefault();
this.element.requestSubmit();
}
Intercept and augment before sending:
import { patch } from '@rails/request.js';
intercept(event) {
event.preventDefault();
const data = new FormData(this.element);
// validate or append items here
patch(this.element.action, { body: data, responseKind: 'turbo-stream' });
}
The three SOLID principles most relevant to Stimulus are Single Responsibility, Open-Closed, and Dependency Inversion. For detailed examples and rationale, read references/solid.md.
Quick summaries:
setup() hook instead of switch/case on type values.For ready-to-use controller implementations, read references/cookbook.md. It contains complete, copy-paste-ready controllers for:
Before committing a Stimulus controller, verify:
static classes, not hardcoded stringsdata-action in markup, not addEventListener in connect()addEventListener is used manually: bound reference stored for disconnect() cleanupconnect(), destroyed in disconnect()teardown() implemented if DOM is mutated; wired via turbo:before-cachethis.element and target operations → consider splitting into two controllersnpx claudepluginhub maquina-app/rails-claude-code --plugin better-stimulusApplies Better Stimulus best practices for writing maintainable, reusable StimulusJS controllers following SOLID principles. Covers configurable controllers, Values API, declarative events, and focused architecture.
Creates and refactors Stimulus controllers using Hotwire conventions, design patterns, targets/values, action handling, and JavaScript best practices for interactive UIs.
Covers Stimulus controller fundamentals: lifecycle hooks, values, targets, outlets, action parameters, keyboard events, and architecture patterns. Best for Stimulus API questions outside Hotwire domains.