better-stimulus · git:20260714.486d7e4 · 2026-07-14 · sha256 33421e2c2e7fd6c8

better-stimulus git:20260714.486d7e4A

Immutable. This exact content is served forever at /api/v1/blob/33421e2c2e7fd6c8.

---
name: better-stimulus
description: >
  Apply opinionated StimulusJS best practices from betterstimulus.com. Use this
  skill whenever writing, reviewing, debugging, or refactoring Stimulus
  controllers. Triggers when user asks to write, fix, or review a Stimulus
  controller, asks about data-controller, data-action, data-target, data-values,
  outlets, lifecycle callbacks, state management in Stimulus, Hotwire patterns,
  or Turbo and Stimulus integration.
---

# Better Stimulus

Opinionated StimulusJS best practices sourced directly from [betterstimulus.com](https://www.betterstimulus.com) / [julianrubisch/better-stimulus](https://github.com/julianrubisch/better-stimulus).

---

## ARCHITECTURE

### 1. Application Controller
Create a base `ApplicationController` that all controllers inherit from. Use it to share lifecycle hooks and utility methods across the app.

```js
// 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.

---

### 2. Configurable Controllers (Late Binding)
Never hardcode dependencies (CSS classes, selectors, IDs) inside controllers. Use the Classes API, Values API, or dataset attributes so controllers are reusable.

Bad:
```js
toggle(e) {
  this.element.classList.toggle("active"); // hardcoded class
}
```

Good:
```html
<a data-controller="toggle" data-action="click->toggle#toggle" data-toggle-active-class="active">
```
```js
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.

---

### 3. Mixins Over Inheritance for Shared Behavior
When behavior is a *role* (not a specialization), use mixins instead of inheritance.

Bad — extending a concrete controller:
```js
import OverlayController from "./overlay_controller";
export default class extends OverlayController { ... }
```

Good — mixin pattern:
```js
// 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](https://github.com/stimulus-use/stimulus-use)

---

### 4. State Management with Values API
Use Stimulus `values` as the single source of truth for controller state — not instance variables.

Bad:
```js
connect() {
  this.markers = []; // instance variable, not serialized
}
addMarker() {
  this.markers.push({...});
}
```

Good:
```js
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.

---

### 5. Namespaced Attributes
When you need an arbitrary set of controller-scoped parameters beyond what Values API provides, namespace them as `data-[controller]-param-[name]`.

```html
<input data-controller="filter"
       data-filter-param-category="cats"
       data-filter-param-rating="5"
       type="text"
       data-action="input->filter#update">
```

```js
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());
}
```

---

### 6. Targetless Controllers
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:
```js
static targets = ["indicator"];
submit() {
  this.indicatorTarget.textContent = "Saving...";
  this.element.requestSubmit();
}
```

Good — split into two focused controllers:
```html
<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.

---

## LIFECYCLE

### 7. Reserve `connect()` for Plugin Init & DOM Preconditions
`connect()` 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:
- **State** → declare it with the Values API
- **Event listeners** → declare them with `data-action` in the markup

Bad:
```js
connect() {
  this.open = false; // state in instance var
  this.buttonTarget.addEventListener("click", this.toggle.bind(this)); // manual listener
}
```

Good:
```html
<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>
```
```js
static values = { open: Boolean };
static classes = ["hidden"];

toggle() {
  this.openValue = !this.openValue;
}

openValueChanged() {
  this.panelTarget.classList.toggle(this.hiddenClass, !this.openValue);
}
```

---

## EVENTS

### 8. Declare Global Events in Markup with `data-action`
Stimulus 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:
```js
connect() {
  document.addEventListener("resize", this.layout.bind(this));
}
```

Good:
```html
<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:
```js
connect() {
  document.addEventListener("click", this.findFoo.bind(this));
}
disconnect() {
  document.removeEventListener("click", this.findFoo.bind(this)); // different reference!
}
```

Good:
```js
connect() {
  this.boundFindFoo = this.findFoo.bind(this);
  document.addEventListener("click", this.boundFindFoo);
}
disconnect() {
  document.removeEventListener("click", this.boundFindFoo);
}
```

---

## INTERACTION (INTER-CONTROLLER COMMUNICATION)

### 9. Make Controllers Talk via Events, Outlets, or Callbacks
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`.

---

## DOM MANIPULATION

### 10. Use `<template>` to Restore DOM State
When an external library removes HTML from the page (e.g., after closing a Bootstrap modal), use a `<template>` element to restore it.

```html
<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>
```

```js
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`).

---

## INTEGRATING THIRD-PARTY LIBRARIES

### 11. Use Lifecycle Events for Setup and Teardown
Scope each library instance to a controller: create it in `connect`, tear it down in `disconnect`.

Bad (global array, manual DOM querying):
```js
let editors = [];
document.addEventListener("turbo:load", function() {
  document.querySelectorAll(".easymde").forEach(function(el) {
    editors.push(new EasyMDE({ element: el }));
  });
});
```

Good:
```js
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.

---

## ERROR HANDLING

### 12. Centralize Error Handling in the Application Controller
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`.

---

## TURBO INTEGRATION

### 13. Global Teardown Before Turbo Caching
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`.

```js
// 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.

---

### 14. Form Submits
Submit forms in response to arbitrary events or intercept them for client-side logic.

Trigger submit on change:
```erb
<%= form_with(model: @article, data: { controller: "form" }) do |f| %>
  <%= select_tag "author", ..., data: { action: "change->form#update" } %>
<% end %>
```
```js
update(event) {
  event.preventDefault();
  this.element.requestSubmit();
}
```

Intercept and augment before sending:
```js
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' });
}
```

---

## SOLID PRINCIPLES

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:**
- **SRP:** One controller, one job. No "page controllers". If it would change for two reasons, split it.
- **OCP:** Use a polymorphic `setup()` hook instead of switch/case on type values.
- **DIP:** Use dynamic imports + Values API to select dependencies at runtime, not hardcoded imports.

---

## COOKBOOK PATTERNS

For ready-to-use controller implementations, read `references/cookbook.md`. It contains complete, copy-paste-ready controllers for:

- **Faceted Search** — Turbo Frame + form → URL params
- **Refresh When Visible** — Page Visibility API + Turbo Stream refresh
- **Auto Sort** — MutationObserver to sort children by data attribute
- **Dark Mode** — localStorage + CSS class toggling + flash-of-white fix
- **Radio Dropdown** — radio-like dropdown with change event dispatch

---

---

## QUICK REFERENCE CHECKLIST

Before committing a Stimulus controller, verify:

- [ ] State is in Values API, not instance variables
- [ ] CSS classes are in `static classes`, not hardcoded strings
- [ ] Events use `data-action` in markup, not `addEventListener` in `connect()`
- [ ] If `addEventListener` is used manually: bound reference stored for `disconnect()` cleanup
- [ ] Controller has a single responsibility (no "page controllers")
- [ ] Third-party libraries initialized in `connect()`, destroyed in `disconnect()`
- [ ] Turbo: `teardown()` implemented if DOM is mutated; wired via `turbo:before-cache`
- [ ] Inter-controller communication: Outlets for direct calls, custom events for broadcast
- [ ] No hardcoded selectors or class names inside controller logic
- [ ] If mixing `this.element` and `target` operations → consider splitting into two controllers