mcpField
S
stimulus

stimulus

github.com/thinkoodle開発7191 回インストール8 週間前に更新
リポジトリ

stimulus について

Rails 開発者向けに、HTML のデータ属性を使ってサーバーレンダリングされたページへ操作性を加える Stimulus のスキルです。小さく組み合わせ可能なコントローラー、段階的拡張、CSS による表示状態の管理を重視します。

主な機能

  1. 01データ属性でHTMLとコントローラーを接続
  2. 02ターゲット・値・クラス・アウトレットの利用
  3. 03小さなコントローラーの組み合わせ
  4. 04ライフサイクル管理とリソース解放
  5. 05CSSクラスによる表示状態の制御
  6. 06接続・ターゲット・アクションのデバッグ

活用シーン

  1. 01サーバーレンダリング画面に切り替えやモーダルを追加
  2. 02フォーム・検索・クリップボード操作の設定
  3. 03接続やアクションが動かない問題の調査
  4. 04コントローラー整理と相互連携

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/thinkoodle/rails-skills --skill stimulus

インストール後、Claude Code で/stimulusを入力して読み込まれたか確認します。

同梱ファイルreference.md

詳細

Rails Stimulus エキスパート

HTML と動作をデータ属性で結び付ける、小さく焦点を絞った JavaScript コントローラーを作成します。

基本方針

基本原則:

  1. HTML ファースト — Stimulus はサーバーでレンダリングされた HTML を拡張するものであり、置き換えるものではありません
  2. 小さなコントローラー — 1 つのコントローラーにつき 1 つの動作を担います。要素に複数のコントローラーを重ねて組み合わせます
  3. プログレッシブエンハンスメント — ページは JavaScript がなくても動作し、コントローラーがインタラクティブ性を追加します
  4. JS でレンダリングしない — コントローラーは DOM の状態(クラス、属性、表示状態)を操作します。HTML 文字列を組み立てることはありません
  5. 設定より規約 — すべてデータ属性で連携するため、イベントを手動でバインドする必要はありません

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)
}

よくある問題:

  1. コントローラーが接続されない → 確認事項:data-controller のスペルミス、ファイル名(snake_case_controller.js)、コントローラーが index.js に登録されているか
  2. ターゲットが見つからない → 確認事項:ターゲット要素がコントローラーの要素の 内側 にあるか、data-{controller}-target の形式が正しいか
  3. アクションが実行されない → 確認事項:data-action の形式が event->controller#method か、メソッドが存在し、スペルミスがないか
  4. 値が更新されない → 確認事項:data-{controller}-{name}-value の形式が正しいか、値の型が static 宣言と一致しているか
  5. コントローラーが予期せず切断される → 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 のデフォルト値
Stringdata-x-name-value="hello"""
Numberdata-x-count-value="5"0
Booleandata-x-open-value="true"false
Arraydata-x-items-value='["a","b"]'[]
Objectdata-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> を使用)
  • トグル/開閉表示
  • フォームのバリデーション
  • デバウンス検索
  • 文字数カウンター
  • 自動送信フォーム
  • 入れ子/名前空間付きコントローラー

避けるべきアンチパターン

  1. 巨大なコントローラー — 80 行を超える場合は、組み合わせ可能な小さな部品に分割します
  2. JS で HTML をレンダリングする — 動的コンテンツには Turbo Streams を使い、Stimulus は状態の切り替えだけを行います
  3. スタイルを直接操作する — クラスを切り替え、見た目の処理は CSS に任せます
  4. 切断時のクリーンアップを忘れる — connect() 内の setInterval、addEventListener、MutationObserver には、それぞれ disconnect() 内でのクリーンアップが必要です
  5. HTML 属性に camelCase を使う — 常に kebab-case にします。data-myThing-url-value ではなく、data-my-thing-url-value を使います
  6. コントローラー要素の外側にアクセスする — コントローラー間の通信にはアウトレットまたはイベントを使います。コントローラー内から document.querySelector を呼び出さないでください
  7. コントローラーにビジネスロジックを記述する — コントローラーは薄く保ち、複雑なロジックはサーバー側に置きます
  8. 設定に値を使わない — URL、期間、しきい値をハードコードしないでください。値を使うと、HTML から動作を設定できます

コメント