stimulus
Best practices for using Stimulus controllers to add JavaScript behavior to HTML
Best use case
stimulus is best used when you need a repeatable AI agent workflow instead of a one-off prompt.
Best practices for using Stimulus controllers to add JavaScript behavior to HTML
Teams using stimulus should expect a more consistent output, faster repeated execution, less prompt rewriting.
When to use this skill
- You want a reusable workflow that can be run more than once with consistent structure.
When not to use this skill
- You only need a quick one-off answer and do not need a reusable workflow.
- You cannot install or maintain the underlying files, dependencies, or repository context.
Installation
Claude Code / Cursor / Codex
Manual Installation
- Download SKILL.md from GitHub
- Place it in
.claude/skills/stimulus/SKILL.mdinside your project - Restart your AI agent — it will auto-discover the skill
How stimulus Compares
| Feature / Agent | stimulus | Standard Approach |
|---|---|---|
| Platform Support | Not specified | Limited / Varies |
| Context Awareness | High | Baseline |
| Installation Complexity | Unknown | N/A |
Frequently Asked Questions
What does this skill do?
Best practices for using Stimulus controllers to add JavaScript behavior to HTML
Where can I find the source code?
You can find the source code on GitHub using the link provided at the top of the page.
SKILL.md Source
# Stimulus Best Practices for Rails Applications
Rule updated on 12/15/2025 to Stimulus version 3.2.2
Stimulus is a modest JavaScript framework designed to augment your HTML with just enough behavior. It connects JavaScript to the DOM via data attributes, keeping your HTML as the source of truth.
For full reference see [https://stimulus.hotwired.dev/](https://stimulus.hotwired.dev/)
## Core Concepts
| Concept | Purpose | Data Attribute |
| ---------- | ---------------------------------------- | ---------------------------------- |
| Controller | JavaScript class that adds behavior | `data-controller="name"` |
| Target | Important elements referenced in JS | `data-name-target="targetName"` |
| Action | Event handlers connecting DOM to methods | `data-action="event->name#method"` |
| Value | Reactive data stored in HTML | `data-name-value-name="value"` |
| Class | CSS classes toggled by the controller | `data-name-class-name="class"` |
| Outlet | References to other controllers | `data-name-outlet-name="selector"` |
---
## When to Use Stimulus
**Use Stimulus for:**
- Toggle visibility (dropdowns, modals, accordions)
- Form enhancements (character counters, auto-submit, validation UI)
- Copy to clipboard
- Keyboard shortcuts
- Animations and transitions
- Client-side filtering/sorting (small datasets)
- Debounced input handlers
- Any behavior that doesn't require server data
**Don't use Stimulus for:**
- Data that should come from the server (use Turbo Streams instead)
- Complex state management (consider if your approach is right)
- Things Turbo already handles (form submission, navigation)
---
## Controller Structure
### File Naming & Location
Controllers that live in `app/javascript/controllers/` and follow the naming convention below are automatically registered.
| File Name | Controller Name | HTML Reference |
| ------------------------- | --------------------- | ----------------------------- |
| `hello_controller.js` | `HelloController` | `data-controller="hello"` |
| `clipboard_controller.js` | `ClipboardController` | `data-controller="clipboard"` |
| `user_form_controller.js` | `UserFormController` | `data-controller="user-form"` |
### Basic Controller Template
```javascript
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "output"]
static values = { url: String, count: Number, active: Boolean }
static classes = ["hidden", "active"]
connect() {
// Called when controller is connected to DOM
}
disconnect() {
// Called when controller is removed from DOM
// Clean up event listeners, timers, etc.
}
// Action methods
toggle() {
this.outputTarget.classList.toggle(this.hiddenClass)
}
}
```
---
## Targets
Targets provide named references to important elements within the controller's scope.
### Defining Targets
```javascript
export default class extends Controller {
static targets = ["input", "submit", "error"]
validate() {
if (this.inputTarget.value.length < 3) {
this.errorTarget.textContent = "Too short"
this.submitTarget.disabled = true
}
}
}
```
### HTML Usage
```erb
<div data-controller="form">
<input data-form-target="input" data-action="input->form#validate">
<span data-form-target="error"></span>
<button data-form-target="submit">Submit</button>
</div>
```
### Target Properties
| Property | Returns | Example |
| --------------------- | --------------------------------- | ----------------- |
| `this.inputTarget` | First matching element (or error) | Single element |
| `this.inputTargets` | Array of all matching elements | `[el1, el2, el3]` |
| `this.hasInputTarget` | Boolean if target exists | `true` / `false` |
---
## Values
Values are reactive data attributes that automatically sync between HTML and JavaScript.
### Defining Values
```javascript
export default class extends Controller {
static values = {
url: String,
count: Number,
active: Boolean,
config: Object,
items: Array,
}
countValueChanged(value, previousValue) {
// Called automatically when count value changes
console.log(`Count changed from ${previousValue} to ${value}`)
}
}
```
### HTML Usage
```erb
<div data-controller="counter"
data-counter-count-value="0"
data-counter-url-value="<%= api_path %>"
data-counter-config-value="<%= { limit: 10 }.to_json %>">
</div>
```
### Value Benefits
- **Reactive**: Changes trigger `*ValueChanged` callbacks
- **Type coercion**: Automatic conversion to declared type
- **Default values**: `static values = { count: { type: Number, default: 0 } }`
- **HTML as source of truth**: State is visible in the DOM
---
## Actions
Actions connect DOM events to controller methods.
### Action Syntax
```
data-action="event->controller#method"
```
### Common Patterns
```erb
<!-- Click event (default for buttons) -->
<button data-action="dropdown#toggle">Menu</button>
<!-- Explicit event -->
<input data-action="input->search#filter">
<!-- Multiple actions -->
<input data-action="focus->form#highlight blur->form#unhighlight">
<!-- Keyboard events with filters -->
<input data-action="keydown.enter->form#submit keydown.escape->form#cancel">
<!-- Window/document events -->
<div data-controller="modal" data-action="keydown.escape@window->modal#close">
<!-- Form events -->
<form data-action="submit->form#validate">
```
### Event Modifiers
| Modifier | Effect |
| ---------- | --------------------------------- |
| `:prevent` | Calls `event.preventDefault()` |
| `:stop` | Calls `event.stopPropagation()` |
| `:self` | Only fires if target is element |
| `:once` | Removes listener after first fire |
```erb
<a href="#" data-action="click->nav#toggle:prevent">Toggle</a>
```
---
## Classes
Classes let you reference CSS classes from your controller without hardcoding them.
### Defining Classes
```javascript
export default class extends Controller {
static classes = ["active", "hidden", "loading"]
toggle() {
this.element.classList.toggle(this.activeClass)
}
load() {
if (this.hasLoadingClass) {
this.element.classList.add(this.loadingClass)
}
}
}
```
### HTML Usage
```erb
<div data-controller="toggle"
data-toggle-active-class="bg-blue-500 text-white"
data-toggle-hidden-class="hidden">
</div>
```
---
## Lifecycle Callbacks
```javascript
export default class extends Controller {
initialize() {
// Called once when controller is first instantiated
// Use for one-time setup that doesn't depend on DOM
}
connect() {
// Called each time controller connects to DOM
// Set up event listeners, fetch data, start timers
}
disconnect() {
// Called when controller disconnects from DOM
// ALWAYS clean up: remove listeners, clear timers, abort fetches
}
}
```
### Cleanup Example
```javascript
export default class extends Controller {
connect() {
this.interval = setInterval(() => this.refresh(), 5000)
this.abortController = new AbortController()
}
disconnect() {
clearInterval(this.interval)
this.abortController.abort()
}
async refresh() {
const response = await fetch(this.urlValue, {
signal: this.abortController.signal,
})
// ...
}
}
```
---
## Common Controller Patterns
### Toggle Controller
```javascript
// toggle_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["content"]
static classes = ["hidden"]
toggle() {
this.contentTarget.classList.toggle(this.hiddenClass)
}
show() {
this.contentTarget.classList.remove(this.hiddenClass)
}
hide() {
this.contentTarget.classList.add(this.hiddenClass)
}
}
```
### Clipboard Controller
```javascript
// clipboard_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["source"]
static values = { successDuration: { type: Number, default: 2000 } }
copy() {
navigator.clipboard.writeText(this.sourceTarget.value)
this.showCopiedState()
}
showCopiedState() {
this.element.dataset.copied = true
setTimeout(() => delete this.element.dataset.copied, this.successDurationValue)
}
}
```
### Debounce Controller
```javascript
// search_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "form"]
static values = { delay: { type: Number, default: 300 } }
search() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.formTarget.requestSubmit()
}, this.delayValue)
}
disconnect() {
clearTimeout(this.timeout)
}
}
```
---
## Integration with Turbo
### Persisting Controllers Across Navigation
Turbo Drive preserves `<head>` but replaces `<body>`. Controllers on body elements disconnect and reconnect. Use values to persist state:
```erb
<!-- State survives Turbo navigation because it's in HTML -->
<div data-controller="sidebar" data-sidebar-open-value="true">
```
### Responding to Turbo Events
```javascript
export default class extends Controller {
connect() {
document.addEventListener("turbo:before-cache", this.cleanup)
}
disconnect() {
document.removeEventListener("turbo:before-cache", this.cleanup)
}
cleanup = () => {
// Reset state before Turbo caches the page
this.element.classList.remove("is-active")
}
}
```
### Working with Turbo Frames
```javascript
export default class extends Controller {
connect() {
this.element.addEventListener("turbo:frame-load", this.onFrameLoad)
}
onFrameLoad = (event) => {
// React to frame content loading
this.updateUI()
}
}
```
---
## Best Practices Summary
1. **Keep controllers small** — One responsibility per controller (< 100 lines ideally)
2. **Use values for state** — Don't store state in instance variables; keep it in data attributes
3. **Always clean up** — Clear timers, abort fetches, remove listeners in `disconnect()`
4. **Prefer HTML over JS** — Use data attributes to configure behavior, not JavaScript
5. **Name actions clearly** — `toggle`, `submit`, `validate` not `handleClick`, `onClick`
6. **Use targets over querySelector** — More explicit and self-documenting
7. **Compose with multiple controllers** — Combine small controllers rather than building monoliths
8. **Let Turbo handle server communication** — Stimulus is for client-side behavior only
---
## Anti-Patterns to Avoid
| Don't | Do Instead |
| ---------------------------------- | ---------------------------------------- |
| Store state in instance variables | Use values (`static values = {}`) |
| Use `querySelector` in controllers | Use targets (`static targets = []`) |
| Hardcode CSS classes | Use classes (`static classes = []`) |
| Forget to clean up in `disconnect` | Always clean up timers, listeners, etc. |
| Make controllers too large | Split into multiple focused controllers |
| Use Stimulus for data fetching | Use Turbo Frames/Streams for server data |
| Duplicate controller logic | Extract shared behavior to base class |Related Skills
hotwire-turbo
Best practices for using Hotwire Turbo to create reactive applications
stimulus
Stimulus JS framework for Symfony UX -- client-side behavior via HTML data attributes, zero server round-trips. Use when creating controllers for DOM manipulation, handling click/input/submit events, managing targets and values, wiring outlets between controllers, wrapping third-party JS libraries, or building toggles, dropdowns, modals, tabs, clipboard interactions. Code triggers: data-controller, data-action, data-target, data-*-value, data-*-class, data-*-outlet, stimulusFetch lazy, connect(), disconnect(), static targets, static values. Also trigger when the user asks "how do I add a click handler", "how to toggle a class", "how to build a dropdown/modal/tabs", "how to wrap a JS library in Symfony", "add keyboard shortcuts", "lazy-load a controller", "listen to global events", "communicate between controllers". Do NOT trigger for partial page updates without JS (use turbo), server-rendered reactivity (use live-component), or reusable Twig templates (use twig-component).
workspace-surface-audit
Audit the active repo, MCP servers, plugins, connectors, env surfaces, and harness setup, then recommend the highest-value ECC-native skills, hooks, agents, and operator workflows. Use when the user wants help setting up Claude Code or understanding what capabilities are actually available in their environment.
ui-demo
Record polished UI demo videos using Playwright. Use when the user asks to create a demo, walkthrough, screen recording, or tutorial video of a web application. Produces WebM videos with visible cursor, natural pacing, and professional feel.
token-budget-advisor
Offers the user an informed choice about how much response depth to consume before answering. Use this skill when the user explicitly wants to control response length, depth, or token budget. TRIGGER when: "token budget", "token count", "token usage", "token limit", "response length", "answer depth", "short version", "brief answer", "detailed answer", "exhaustive answer", "respuesta corta vs larga", "cuántos tokens", "ahorrar tokens", "responde al 50%", "dame la versión corta", "quiero controlar cuánto usas", or clear variants where the user is explicitly asking to control answer size or depth. DO NOT TRIGGER when: user has already specified a level in the current session (maintain it), the request is clearly a one-word answer, or "token" refers to auth/session/payment tokens rather than response size.
skill-comply
Visualize whether skills, rules, and agent definitions are actually followed — auto-generates scenarios at 3 prompt strictness levels, runs agents, classifies behavioral sequences, and reports compliance rates with full tool call timelines
santa-method
Multi-agent adversarial verification with convergence loop. Two independent review agents must both pass before output ships.
safety-guard
Use this skill to prevent destructive operations when working on production systems or running agents autonomously.
repo-scan
Cross-stack source code asset audit — classifies every file, detects embedded third-party libraries, and delivers actionable four-level verdicts per module with interactive HTML reports.
project-flow-ops
Operate execution flow across GitHub and Linear by triaging issues and pull requests, linking active work, and keeping GitHub public-facing while Linear remains the internal execution layer. Use when the user wants backlog control, PR triage, or GitHub-to-Linear coordination.
product-lens
Use this skill to validate the "why" before building, run product diagnostics, and pressure-test product direction before the request becomes an implementation contract.
openclaw-persona-forge
为 OpenClaw AI Agent 锻造完整的龙虾灵魂方案。根据用户偏好或随机抽卡, 输出身份定位、灵魂描述(SOUL.md)、角色化底线规则、名字和头像生图提示词。 如当前环境提供已审核的生图 skill,可自动生成统一风格头像图片。 当用户需要创建、设计或定制 OpenClaw 龙虾灵魂时使用。 不适用于:微调已有 SOUL.md、非 OpenClaw 平台的角色设计、纯工具型无性格 Agent。 触发词:龙虾灵魂、虾魂、OpenClaw 灵魂、养虾灵魂、龙虾角色、龙虾定位、 龙虾剧本杀角色、龙虾游戏角色、龙虾 NPC、龙虾性格、龙虾背景故事、 lobster soul、lobster character、抽卡、随机龙虾、龙虾 SOUL、gacha。