stimulus について
Rails 開発者向けに、HTML のデータ属性を使ってサーバーレンダリングされたページへ操作性を加える Stimulus のスキルです。小さく組み合わせ可能なコントローラー、段階的拡張、CSS による表示状態の管理を重視します。
主な機能
- 01データ属性でHTMLとコントローラーを接続
- 02ターゲット・値・クラス・アウトレットの利用
- 03小さなコントローラーの組み合わせ
- 04ライフサイクル管理とリソース解放
- 05CSSクラスによる表示状態の制御
- 06接続・ターゲット・アクションのデバッグ
活用シーン
- 01サーバーレンダリング画面に切り替えやモーダルを追加
- 02フォーム・検索・クリップボード操作の設定
- 03接続やアクションが動かない問題の調査
- 04コントローラー整理と相互連携
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ thinkoodle/ rails-skills --skill stimulusインストール後、Claude Code で/stimulusを入力して読み込まれたか確認します。
同梱ファイルreference.md
詳細
Rails Stimulus エキスパート
HTML と動作をデータ属性で結び付ける、小さく焦点を絞った JavaScript コントローラーを作成します。
基本方針
基本原則:
- HTML ファースト — Stimulus はサーバーでレンダリングされた HTML を拡張するものであり、置き換えるものではありません
- 小さなコントローラー — 1 つのコントローラーにつき 1 つの動作を担います。要素に複数のコントローラーを重ねて組み合わせます
- プログレッシブエンハンスメント — ページは JavaScript がなくても動作し、コントローラーがインタラクティブ性を追加します
- JS でレンダリングしない — コントローラーは DOM の状態(クラス、属性、表示状態)を操作します。HTML 文字列を組み立てることはありません
- 設定より規約 — すべてデータ属性で連携するため、イベントを手動でバインドする必要はありません
Stimulus のメンタルモデル:
HTML (data attributes) → Controller (JS behavior) → DOM changes (classes, text, visibility)
↑ source of truth ↑ small & focused ↑ CSS does the heavy lifting
このスキルを使用する場面
- 新しい Stimulus コントローラーを作成するとき
- データ属性を使ってコントローラーを HTML に接続するとき
- サーバーでレンダリングされたビューにインタラクティブ性(トグル、モーダル、クリップボード、フラッシュメッセージ、フォーム)を追加するとき
- コントローラーの接続に関する問題をデバッグするとき
- コントローラーファイルとインポートを整理するとき
- values、targets、classes、outlets、ライフサイクルコールバックを使用するとき
- outlets またはカスタムイベントを使ってコントローラー間で通信するとき
手順
ステップ 1:既存のコントローラーを確認する
新しいコントローラーを作成する前に、必ず既存のコントローラーを検索してください。
# List all controllers
ls app/javascript/controllers/
# Search for similar behavior
rg "static targets" app/javascript/controllers/
rg "static values" app/javascript/controllers/
# Check if there's a matching controller already
rg "data-controller=\"toggle\"" app/views/
既存プロジェクトの規約に合わせてください — 命名、スタイル、パターンなどです。「理想的な」方法より一貫性を優先します。
ステップ 2:コントローラーを生成または作成する
Rails ジェネレーターを使用します。
bin/rails generate stimulus example
# Creates: app/javascript/controllers/example_controller.js
# Updates: app/javascript/controllers/index.js (if not using auto-loading)
または、手動で作成します。
// app/javascript/controllers/example_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
}
}
app/javascript/controllers/ 内のコントローラーは、index.js によって自動登録されます。
// app/javascript/controllers/index.js
import { application } from "./application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
ステップ 3:コントローラーのインターフェースを定義する
targets、values、classes、outlets はクラスの先頭で静的に宣言します。
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "output", "submit"]
static values = {
url: String,
count: { type: Number, default: 0 },
enabled: Boolean,
items: Array,
config: Object
}
static classes = ["active", "loading", "hidden"]
static outlets = ["other-controller"]
// Lifecycle, then actions
connect() { }
disconnect() { }
// Action methods
toggle() { }
submit() { }
}
順序の規約: 静的宣言 → ライフサイクル → アクション → プライベートヘルパー。
ステップ 4:データ属性で HTML と連携させる
⚠️ 重要:データ属性の命名が、バグの最大の原因です。
ルール:
- コントローラー名:HTML では kebab-case(
data-controller="my-thing")、ファイル名では snake_case(my_thing_controller.js)を使用します。HTML では camelCase を使用しません - 複数語の値:HTML 属性では kebab-case、JavaScript でのアクセスでは camelCase を使用します
- Target 属性の形式:
data-{controller}-target="{name}" - Value 属性の形式:
data-{controller}-{name}-value="{val}" - Class 属性の形式:
data-{controller}-{class}-class="{css-class}" - Action の形式:
data-action="{event}->{controller}#{method}"
<%# Controller with values, targets, and actions %>
<div data-controller="search"
data-search-url-value="<%= search_path %>"
data-search-debounce-value="300"
data-search-active-class="is-active">
<input data-search-target="input"
data-action="input->search#query keydown.escape->search#clear"
type="text"
placeholder="Search...">
<div data-search-target="results"></div>
</div>
エージェントがよく間違える命名:
<%# WRONG — camelCase in HTML attribute %>
<div data-controller="myThing">
<div data-myThing-url-value="/api">
<%# CORRECT — kebab-case in HTML %>
<div data-controller="my-thing">
<div data-my-thing-url-value="/api">
<%# WRONG — wrong target format %>
<div data-target="search.input">
<%# CORRECT — namespaced target format %>
<div data-search-target="input">
ステップ 5:アクションを正しく処理する
デフォルトのイベント(イベント名は省略できます):
| 要素 | デフォルトのイベント |
|---|---|
<button> | click |
<input> | input |
<select> | change |
<form> | submit |
<a> | click |
<textarea> | input |
<details> | toggle |
<%# These are equivalent for a button: %>
<button data-action="click->toggle#flip">Toggle</button>
<button data-action="toggle#flip">Toggle</button>
<%# Multiple actions on one element: %>
<input data-action="input->search#query focus->search#expand blur->search#collapse">
<%# Keyboard modifiers: %>
<input data-action="keydown.enter->form#submit keydown.escape->form#cancel">
<%# Event options: %>
<a data-action="click->nav#toggle:prevent">Link</a>
<button data-action="click->menu#close:stop">Close</button>
<div data-action="scroll->lazy#load:once">Load once</div>
使用可能なキー修飾子: enter、tab、esc、space、up、down、left、right、home、end、および任意の KeyboardEvent.key 値。
アクションオプション: :prevent(preventDefault)、:stop(stopPropagation)、:once(初回呼び出し後に削除)、:self(event.target が要素自身の場合のみ)。
ステップ 6:ライフサイクルコールバックを使用する
export default class extends Controller {
// Called once when controller class is first instantiated
// Use for: one-time setup like binding methods for callbacks
initialize() {
this.search = this.search.bind(this)
}
// Called every time the controller's element enters the DOM
// Use for: setting up timers, observers, fetching initial data
connect() {
this.interval = setInterval(() => this.poll(), 5000)
}
// Called every time the controller's element leaves the DOM
// Use for: cleanup! Timers, observers, event listeners
disconnect() {
clearInterval(this.interval)
}
// Target connected/disconnected callbacks
outputTargetConnected(element) {
// Called when a new output target appears in DOM
}
outputTargetDisconnected(element) {
// Called when an output target is removed from DOM
}
// Value change callbacks
countValueChanged(newValue, oldValue) {
this.outputTarget.textContent = newValue
}
}
⚠️ disconnect() で必ずクリーンアップしてください。 Stimulus コントローラーは DOM の変更(Turbo ナビゲーション、Turbo Streams など)に応じて接続・切断されます。タイマーやオブザーバーの解放漏れは、Stimulus で最もよくあるバグです。
ステップ 7:コントローラーを小さく保つ
コントローラー 1 つにつき動作は 1 つです。重ねて組み合わせてください。
<%# Good — two focused controllers %>
<div data-controller="dropdown tooltip">
<button data-action="click->dropdown#toggle mouseenter->tooltip#show mouseleave->tooltip#hide">
Options
</button>
</div>
<%# Bad — one mega-controller doing everything %>
<div data-controller="dropdown-with-tooltip-and-keyboard-nav">
コントローラーが約 80 行を超える場合、機能を詰め込みすぎている可能性があります。分割してください。
ステップ 8:視覚的な状態には CSS を使用する
コントローラーはクラスを切り替え、描画は CSS が行います。
// Good — controller manages state
toggle() {
this.element.classList.toggle(this.activeClass)
}
// Bad — controller manages appearance
toggle() {
this.element.style.display = this.element.style.display === "none" ? "block" : "none"
this.element.style.opacity = "1"
this.element.style.transform = "translateY(0)"
}
/* CSS handles all visual transitions */
.dropdown { display: none; }
.dropdown.is-active { display: block; }
ステップ 9:アウトレットを使ったコントローラー間の通信
アウトレットを使うと、あるコントローラーから別のコントローラーを参照し、そのメソッドを呼び出せます。
// tabs_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static outlets = ["panel"]
select(event) {
const index = event.currentTarget.dataset.index
this.panelOutlets.forEach((panel, i) => {
panel.toggle(i === parseInt(index))
})
}
}
<div data-controller="tabs" data-tabs-panel-outlet=".tab-panel">
<button data-action="click->tabs#select" data-index="0">Tab 1</button>
<button data-action="click->tabs#select" data-index="1">Tab 2</button>
<div class="tab-panel" data-controller="panel">Content 1</div>
<div class="tab-panel" data-controller="panel">Content 2</div>
</div>
アウトレットの命名: data-{controller}-{outlet-name}-outlet 属性では、コントローラー名を kebab-case で指定します。アウトレットの値は、対象のコントローラーを持つ要素に一致する CSS セレクター です。
代替方法:カスタムイベント — アウトレットの結びつきが強すぎると感じる場合は、より疎結合な方法を使います。
// Dispatching controller
this.dispatch("selected", { detail: { index: 0 } })
// Listening in HTML
<div data-action="tabs:selected->panel#activate">
ステップ 10:デバッグ
// Enable debug mode in browser console
Stimulus.debug = true
// Shows: connect/disconnect events, action dispatches, value changes
// Add logging in connect() for troubleshooting
connect() {
console.log(`${this.identifier} connected`, this.element)
console.log("targets:", this.outputTargets)
console.log("values:", this.urlValue, this.countValue)
}
よくある問題:
- コントローラーが接続されない → 確認事項:
data-controllerのスペルミス、ファイル名(snake_case_controller.js)、コントローラーがindex.jsに登録されているか - ターゲットが見つからない → 確認事項:ターゲット要素がコントローラーの要素の 内側 にあるか、
data-{controller}-targetの形式が正しいか - アクションが実行されない → 確認事項:
data-actionの形式がevent->controller#methodか、メソッドが存在し、スペルミスがないか - 値が更新されない → 確認事項:
data-{controller}-{name}-valueの形式が正しいか、値の型が static 宣言と一致しているか - コントローラーが予期せず切断される → Turbo ナビゲーションによって DOM が置き換えられています。コントローラーの要素が維持されるか、適切に再アタッチされることを確認してください。
クイックリファレンス
ターゲットへのアクセス
this.outputTarget // First matching target (throws if missing)
this.outputTargets // Array of all matching targets
this.hasOutputTarget // Boolean — does at least one exist?
値へのアクセス
this.urlValue // Get
this.urlValue = "/new" // Set (triggers valueChanged callback)
this.hasUrlValue // Boolean — was it specified in HTML?
クラスへのアクセス
this.activeClass // Single class string, e.g. "is-active"
this.activeClasses // Array of classes
this.hasActiveClass // Boolean
アウトレットへのアクセス
this.panelOutlet // First matching outlet controller
this.panelOutlets // Array of all matching outlet controllers
this.hasPanelOutlet // Boolean
this.panelOutletElement // The DOM element of the first outlet
this.panelOutletElements // Array of DOM elements
値の型
| 型 | HTML の例 | JS のデフォルト値 |
|---|---|---|
String | data-x-name-value="hello" | "" |
Number | data-x-count-value="5" | 0 |
Boolean | data-x-open-value="true" | false |
Array | data-x-items-value='["a","b"]' | [] |
Object | data-x-config-value='{"k":"v"}' | {} |
ファイル構成
app/javascript/
├── application.js # Entry point, imports controllers
├── controllers/
│ ├── application.js # Base controller (extend this)
│ ├── index.js # Auto-loader registration
│ ├── clipboard_controller.js # Simple, focused controllers
│ ├── dropdown_controller.js
│ ├── flash_controller.js
│ ├── modal_controller.js
│ ├── toggle_controller.js
│ └── form_validation_controller.js
命名: {behavior}_controller.js — 用途 ではなく、何を するか に基づいて名前を付けます。
- ✅
toggle_controller.js、clipboard_controller.js、auto_submit_controller.js - ❌
sidebar_controller.js、header_controller.js、user_form_controller.js
よくあるパターン
完全な実装例については、reference.md を参照してください。
- 視覚的なフィードバックを伴うクリップボードへのコピー
- 自動的に消えるフラッシュメッセージ
- モーダルダイアログ(
<dialog>を使用) - トグル/開閉表示
- フォームのバリデーション
- デバウンス検索
- 文字数カウンター
- 自動送信フォーム
- 入れ子/名前空間付きコントローラー
避けるべきアンチパターン
- 巨大なコントローラー — 80 行を超える場合は、組み合わせ可能な小さな部品に分割します
- JS で HTML をレンダリングする — 動的コンテンツには Turbo Streams を使い、Stimulus は状態の切り替えだけを行います
- スタイルを直接操作する — クラスを切り替え、見た目の処理は CSS に任せます
- 切断時のクリーンアップを忘れる —
connect()内のsetInterval、addEventListener、MutationObserverには、それぞれdisconnect()内でのクリーンアップが必要です - HTML 属性に camelCase を使う — 常に kebab-case にします。
data-myThing-url-valueではなく、data-my-thing-url-valueを使います - コントローラー要素の外側にアクセスする — コントローラー間の通信にはアウトレットまたはイベントを使います。コントローラー内から
document.querySelectorを呼び出さないでください - コントローラーにビジネスロジックを記述する — コントローラーは薄く保ち、複雑なロジックはサーバー側に置きます
- 設定に値を使わない — URL、期間、しきい値をハードコードしないでください。値を使うと、HTML から動作を設定できます