rails-cto-stimulus
Skill15 starsChanged 7 months agoMITView on GitHub
How to write Stimulus controllers for this Rails 8 / Hotwire project. Use when creating, modifying, or debugging Stimulus controllers, wiring controllers to ERB views, or integrating JavaScript behavior with Turbo Streams and Frames. Also use when the user mentions "stimulus", "controller", "data-action", "data-controller", "targets", "values", or asks about frontend interactivity.
- Installs packages
- Sends data out
In this repository17 other files it gives its agents.
- claude/CLAUDE.mdCLAUDE.md
- rails-cto-apiSkill
- rails-cto-architectSkill
- rails-cto-commitSkill
- rails-cto-engineerSkill
- rails-cto-erbSkill
- rails-cto-minitestSkill
- rails-cto-production-prSkill
- rails-cto-pull-requestSkill
- rails-cto-qaSkill
- rails-cto-restfulSkill
- rails-cto-securitySkill
- rails-ctoSkill
- rails-cto-static-analysisSkill
- rails-cto-tailwindSkill
- rails-upgradeSkill
- rails-viewcomponentsSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
25 rules. About 0 are common, 11 are distinctive, and 14 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈11
Distinctive · estimate
This author's own choices. Worth a look.
14
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 16
Before writing a new controller, search the existing controllers in app/javascript/controllers/ and app/components/ for one that already …
- Distinctive: line 22
Every Stimulus controller must be designed to work across multiple views and contexts.
- Distinctive: line 55
If you find yourself including a feature name (post, user, feed, setting) in the controller filename, stop and rethink.
- Very specific: line 78
Every controller is manually imported and registered in index.js.
- Very specific: line 98
Follow this order inside every controller.
- Very specific: line 136
Always import { Controller } from "@hotwired/stimulus" — not ApplicationController, unless you specifically need its behavior
- Very specific: line 137
Use export default class extends Controller (anonymous class)
- Very specific: line 139
Use hasXTarget / hasXValue guards before accessing optional targets/values
- Distinctive: line 140
Document every public method with a JSDoc comment describing why it exists, not what it does:
- Very specific: line 190
Use the *ValueChanged() callback to react to changes:
- Very specific: line 263
Always clean up in disconnect() to prevent memory leaks:
- Distinctive: line 273
When binding listeners manually, store the bound reference so you can remove it:
- Distinctive: line 284
Every controller that makes fetch requests uses this getter:
- Distinctive: line 294
Use try-catch for fetch calls.
- Distinctive: line 333
Before writing or running Stimulus controller tests, confirm the project has Vitest configured.
- Distinctive: line 350
If Vitest is not installed or configured, inform the user:
- Distinctive: line 377
If Vitest is not available, skip the testing sections below but still flag that tests are missing.
- Distinctive: line 381
Every new or modified Stimulus controller must have a corresponding test file.
- Distinctive: line 388
If the test file does not exist, create it.
- Very specific: line 450
Always run yarn test after modifying any Stimulus controller.
- Very specific: line 454
When working on Stimulus controllers, also invoke /better-stimulus@obie-skills for additional best practices and patterns from …
- Very specific: line 461
Add import + application.register() to index.js
- Very specific: line 462
Use the identifier in your ERB with data-controller="namespace--name"
- Very specific: line 465
Use #private methods for internal logic
- Very specific: line 466
Prefer data-action in HTML over manual addEventListener when possible
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Stimulus Controllers in This Project
Reuse First, Create Second
Before writing a new controller, search the existing controllers in app/javascript/controllers/ and app/components/ for one that already does what you need. Many behaviors are already covered — autosave, clipboard, redirect, sortable, hover toggling, reset form, and more live in utils/. Check index.js for the full inventory.
If no existing controller fits, design the new one for general-purpose reuse. Controllers should not be tightly coupled to a single view or feature. Keep behavior generic and configurable through values, targets, and action parameters so the same controller can be wired into different contexts without duplication. Place broadly reusable controllers in utils/; only use domain namespaces (posts/, feeds/) when the behavior is truly domain-specific.
NEVER create single-purpose controllers
Every Stimulus controller must be designed to work across multiple views and contexts. Ask: "Could another page need this same behavior?" If yes — and the answer is almost always yes — name and design it generically.
Name controllers after the behavior, not the feature:
| Task | WRONG (single-purpose) | RIGHT (reusable) |
| Save form when checkbox toggled | post_auto_check_controller.js | toggle_submit_controller.js |
| Dismiss a flash message | flash_close_controller.js | dismissable_controller.js |
| Copy text to clipboard on click | share_link_copy_controller.js | clipboard_controller.js |
| Show/hide a section | settings_panel_toggle_controller.js | toggle_element_controller.js |
| Submit form on input change | search_auto_submit_controller.js | auto_submit_controller.js |
| Confirm before destructive action | delete_post_confirm_controller.js | confirm_action_controller.js |
Drive specifics through values and targets, not hard-coded selectors:
// WRONG — hard-coded to one specific form
export default class extends Controller {
save() {
document.querySelector("#post-settings-form").requestSubmit();
}
}
// RIGHT — works with any form via targets
export default class extends Controller {
static targets = ["form"];
save() {
this.formTarget.requestSubmit();
}
}If you find yourself including a feature name (post, user, feed, setting) in the controller filename, stop and rethink. The controller is probably too specific.
File Layout
app/javascript/controllers/
├── index.js # All registrations live here
├── application_controller.js # Minimal base class
├── utils.js # Shared helper functions
├── transition.js # Reusable enter/leave transitions
│
├── <name>_controller.js # Root-level controllers
├── posts/ # Domain-namespaced controllers
├── users/
├── accounts/
├── stripe/
└── utils/ # Small, reusable utility controllers
app/components/**/ # ViewComponent controllers live with their componentsRegistration
Every controller is manually imported and registered in index.js.
There is no auto-loading — each new controller needs two lines added here.
// app/javascript/controllers/index.js
import AutosaveController from "./utils/autosave_controller";
application.register("utils--autosave", AutosaveController);Naming rules for the identifier string:
| Controller location | Identifier pattern | Example |
utils/autosave_controller.js | utils--autosave | Double-dash separates namespace |
posts/list_reorder_controller.js | posts--list-reorder | Underscores become hyphens |
reader_controller.js (root) | reader | No namespace prefix |
app/components/overlays/modal_controller.js | overlays--modal | Components use their directory |
Controller Structure
Follow this order inside every controller. See app/javascript/controllers/utils/autosave_controller.js as a compact reference:
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
// 1. Static declarations — always at the top
static targets = ["form", "submit"];
static values = { delay: Number, refreshInterval: Number };
static outlets = []; // only if needed
static classes = []; // only if needed
// 2. Lifecycle hooks
connect() {
this.timeout = null;
this.duration = this.hasDelayValue ? this.delayValue : 1500;
}
disconnect() {
// Clean up listeners, timers, intervals
}
// 3. Public action methods (called from data-action)
save() { /* ... */ }
submit() { /* ... */ }
// 4. Private helpers (use # prefix)
#processResponse(html) { /* ... */ }
// 5. Private getters
get #csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content;
}
}Key conventions:
- Always
import { Controller } from "@hotwired/stimulus"— not ApplicationController, unless you specifically need its behavior - Use
export default class extends Controller(anonymous class) - Declare
static targets,static values, etc. even if empty arrays aren't needed — only declare what you use - Use
hasXTarget/hasXValueguards before accessing optional targets/values - Document every public method with a JSDoc comment describing why it exists, not what it does:
/**
* Debounces form submissions to prevent duplicate saves
* when the user rapidly edits fields in the autosave form.
*/
save() { /* ... */ }Wiring in ERB Views
<%# Controller + values %>
<div data-controller="utils--autosave"
data-utils--autosave-delay-value="2000">
<%# Targets %>
<form data-utils--autosave-target="form">
<%# Actions — format: event->identifier#method %>
<input data-action="input->utils--autosave#save">
<button data-utils--autosave-target="submit">Save</button>
</form>
</div>Multiple controllers on one element — space-separated:
<div data-controller="hover-toggler app--post">Values
Declare with types. Stimulus coerces automatically.
static values = {
url: String,
delay: Number,
open: Boolean,
items: Array,
config: Object,
// With defaults:
duration: { type: Number, default: 300 }
};Use the *ValueChanged() callback to react to changes:
openValueChanged() {
this.applyVisibility();
}Cross-Controller Communication
Outlets — direct access to another controller instance:
static outlets = ["reader"];
summarize() {
this.readerOutlet.iframeTarget.classList.add("hidden");
}<div data-controller="reader-summary"
data-reader-summary-reader-outlet="#reader-frame">Custom events — loose coupling via document:
// Dispatch
document.dispatchEvent(new CustomEvent("post:drag-start", { detail: { id } }));
// Listen (bind in connect, remove in disconnect)
connect() {
this.onDragStart = this.#handleDragStart.bind(this);
document.addEventListener("post:drag-start", this.onDragStart);
}
disconnect() {
document.removeEventListener("post:drag-start", this.onDragStart);
}Turbo Integration
Rendering Turbo Streams from JS:
const response = await fetch(url, {
method: "PATCH",
headers: {
"Accept": "text/vnd.turbo-stream.html",
"X-CSRF-Token": this.#csrfToken
}
});
const html = await response.text();
Turbo.renderStreamMessage(html);Turbo Frame navigation:
this.frameTarget.src = newUrl;Listening to Turbo events:
document.addEventListener("turbo:before-stream-render", this.#boundBeforeRender);
document.addEventListener("turbo:frame-load", () => { /* ... */ });Cleanup
Always clean up in disconnect() to prevent memory leaks:
disconnect() {
clearTimeout(this.timeout);
clearInterval(this.interval);
document.removeEventListener("keydown", this.boundKeyHandler);
}When binding listeners manually, store the bound reference so you can remove it:
connect() {
this.boundKeyHandler = this.#handleKey.bind(this);
document.addEventListener("keydown", this.boundKeyHandler);
}CSRF Token Pattern
Every controller that makes fetch requests uses this getter:
get #csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content;
}Error Handling
Use try-catch for fetch calls; fail silently for non-critical browser APIs:
async #loadContent() {
try {
const response = await fetch(this.urlValue);
if (!response.ok) throw new Error("Network response was not ok");
const html = await response.text();
this.contentTarget.innerHTML = html;
} catch (error) {
console.error("Failed to load content:", error);
}
}Animation Helpers
The project provides transition.js for enter/leave animations:
import { transition } from "../transition";
async toggle() {
await transition(this.panelTarget, this.openValue);
}For frame-perfect DOM updates, use double-rAF:
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// DOM is fully painted before this runs
});
});Pre-flight: Verify Vitest Setup
Before writing or running Stimulus controller tests, confirm the project has Vitest configured.
- Check for Vitest in package.json:
grep -q '"vitest"' package.json && echo "vitest installed" || echo "vitest missing" - Check for a Vitest config file — look for
vitest.config.js,vitest.config.ts, or atestsection invite.config.js:ls vitest.config.* vite.config.* 2>/dev/null - Check the test script exists:
grep '"test"' package.json
If Vitest is not installed or configured, inform the user:
"This project doesn't have Vitest set up for JavaScript tests. To enable Stimulus controller testing, add these to your project:
yarn add -D vitest @testing-library/dom jsdomvitest.config.js:
import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "jsdom", globals: true, }, });package.json (add to scripts):
"test": "vitest run"Then run
yarn install."
If Vitest is not available, skip the testing sections below but still flag that tests are missing.
Testing Stimulus Controllers
Every new or modified Stimulus controller must have a corresponding test file. Map by convention:
app/javascript/controllers/utils/autosave_controller.js→test/javascript/utils/autosave_controller.test.jsapp/javascript/controllers/posts/list_reorder_controller.js→test/javascript/posts/list_reorder_controller.test.jsapp/javascript/controllers/reader_controller.js→test/javascript/reader_controller.test.jsapp/components/overlays/modal_controller.js→test/javascript/components/overlays/modal_controller.test.js
If the test file does not exist, create it.
Test structure
import { Application } from "@hotwired/stimulus";
import AutosaveController from "controllers/utils/autosave_controller";
describe("AutosaveController", () => {
let application;
beforeEach(() => {
document.body.innerHTML = `
<div data-controller="utils--autosave"
data-utils--autosave-delay-value="500">
<form data-utils--autosave-target="form">
<input data-action="input->utils--autosave#save" />
<button data-utils--autosave-target="submit">Save</button>
</form>
</div>
`;
application = Application.start();
application.register("utils--autosave", AutosaveController);
});
afterEach(() => {
application.stop();
document.body.innerHTML = "";
});
it("connects the controller", () => {
const element = document.querySelector("[data-controller='utils--autosave']");
expect(element).not.toBeNull();
});
it("sets the delay value from the attribute", () => {
const controller = application.getControllerForElementAndIdentifier(
document.querySelector("[data-controller='utils--autosave']"),
"utils--autosave"
);
expect(controller.delayValue).toBe(500);
});
});What to test
- connect/disconnect — controller initializes state, cleans up timers/listeners
- Values — default values are set,
*ValueChangedcallbacks fire - Targets — elements are found, missing targets are handled gracefully
- Actions — public methods respond correctly to simulated events
- Fetch calls — mock
fetchand verify correct URL, method, headers, and response handling - DOM updates — verify the controller modifies the DOM as expected
Running tests
yarn test # Full JS test suite
yarn test test/javascript/utils/autosave_controller.test.js # Single fileAlways run yarn test after modifying any Stimulus controller. Tests must pass before the work is considered done.
Marketplace Reference
When working on Stimulus controllers, also invoke /better-stimulus@obie-skills for additional best practices and patterns from betterstimulus.com. This skill complements the project-specific conventions above with broader community patterns for writing clean, maintainable Stimulus code.
Checklist for New Controllers
- Search existing controllers first — check
utils/,app/components/, andindex.jsfor something reusable - Design for reuse — keep behavior generic, drive specifics through values and targets
- Create
app/javascript/controllers/<namespace>/<name>_controller.js - Add import +
application.register()toindex.js - Use the identifier in your ERB with
data-controller="namespace--name" - Declare only the static properties you actually use
- Clean up timers, intervals, and listeners in
disconnect() - Use
#privatemethods for internal logic - Prefer
data-actionin HTML over manualaddEventListenerwhen possible - Write tests — create a corresponding
.test.jsfile and runyarn test
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

