Stimulus(Hotwire)は「HTMLが主役、JavaScriptは振る舞いを添えるだけ」という思想のフレームワークです。本ドキュメントでは Stimulus 3.x を前提に、保守しやすいコントローラを書くための指針をまとめます。


目次

  1. 基本思想を押さえる
  2. コントローラ設計
  3. Targets
  4. Values(状態と設定)
  5. CSS Classes
  6. Actions
  7. ライフサイクルとクリーンアップ
  8. コントローラ間の連携
  9. Turbo との併用
  10. 登録と読み込み
  11. TypeScript を使う場合
  12. テスト
  13. アンチパターン集
  14. チェックリスト

1. 基本思想を押さえる

  • 状態はDOMに持たせる: JSの変数ではなく、data-*-value 属性やクラス、要素の有無で状態を表現する。サーバーから返したHTMLがそのまま「正しい状態」になる。
  • HTMLをサーバーで描画し、JSは補助に徹する: クライアント側でHTMLを組み立てるのは最小限に。描画が必要なら Turbo Frames / Turbo Streams を優先する。
  • Progressive Enhancement: JSが動かなくても最低限の機能(リンク・フォーム送信)は成立するように作る。

2. コントローラ設計

小さく、汎用的に

ページ単位ではなく 振る舞い単位 でコントローラを作る。

❌ user_profile_page_controller.js   … ページ専用で何でもやる
✅ clipboard_controller.js           … コピーする
✅ toggle_controller.js              … 表示/非表示を切り替える
✅ autosubmit_controller.js          … 入力時にフォームを送信する
  • 1つのコントローラは1つの責務に絞る。
  • 複数の振る舞いが必要なら、1要素に複数コントローラを付ければよい。
<div data-controller="toggle clipboard">...</div>

命名規則

ファイル識別子
hello_controller.jshello
date_picker_controller.jsdate-picker
users/list_item_controller.jsusers--list-item
  • 識別子は kebab-case。
  • 名前は「何をするか」を表す動詞・機能名にする(dropdown, autosave, char-counter など)。

3. Targets

export default class extends Controller {
  static targets = ["input", "output"]
 
  copy() {
    this.outputTarget.textContent = this.inputTarget.value
  }
}
<div data-controller="preview">
  <input data-preview-target="input" data-action="input->preview#copy">
  <p data-preview-target="output"></p>
</div>
  • querySelector を使わず Targets を使う。HTMLとJSの依存関係が属性で明示される。
  • 任意要素は this.hasXxxTarget で存在確認してから触る(存在しないと xxxTarget は例外を投げる)。
  • 複数要素は this.xxxTargets(配列)を使う。
  • 要素の動的な追加・削除に反応したいときは Target callbacks を使う。
itemTargetConnected(element) { this.updateCount() }
itemTargetDisconnected(element) { this.updateCount() }

4. Values(状態と設定)

export default class extends Controller {
  static values = {
    url: String,
    interval: { type: Number, default: 5000 },
    open: Boolean,
  }
 
  openValueChanged(value, previousValue) {
    this.element.hidden = !value
  }
}
<div data-controller="poller"
     data-poller-url-value="/notifications"
     data-poller-interval-value="10000">
</div>
  • 設定値(URL、間隔、閾値など)はハードコードせず Values で受け取る。
  • 状態は Values に持たせ、xxxValueChanged コールバックでUIを更新する。状態変更とDOM反映が分離され、どこから値を変えても一貫して動く。
  • xxxValueChanged は connect 前の初期化時にも呼ばれるため、初期描画の処理もここに集約できる。
  • 型(String / Number / Boolean / Array / Object)を指定し、default を活用する。

5. CSS Classes

export default class extends Controller {
  static classes = ["active", "loading"]
 
  start() {
    this.element.classList.add(this.loadingClass)
  }
}
<button data-controller="submit"
        data-submit-loading-class="opacity-50 cursor-wait">
  • クラス名をJSに直書きしない。Tailwind 等のユーティリティクラスを使う場合に特に有効。
  • 複数クラスは this.loadingClasses(配列)で取得できる。
  • 同じコントローラをデザインの異なる場所で再利用しやすくなる。

6. Actions

基本

<button data-action="click->dialog#open">開く</button>
  • イベント名が要素のデフォルト(button の click、form の submit、input の input など)なら省略可: data-action="dialog#open"。
  • ただし可読性のため、チーム内でどちらに揃えるか決めておくとよい。

Action Options

event.preventDefault() などを属性で宣言できる。

<a href="#" data-action="click->menu#toggle:prevent">メニュー</a>
オプション効果
:preventpreventDefault()
:stopstopPropagation()
:selfイベントのターゲットが要素自身の時だけ発火
:once1回だけ発火
:passive / :captureリスナーオプション

キーボードフィルタとグローバルイベント

<input data-action="keydown.enter->search#submit keydown.esc->search#clear">
<div data-action="resize@window->layout#update click@document->dropdown#closeOnOutside">
  • @window / @document を使えば、addEventListener を手動で書く必要がなく、解除も自動で行われる。

Action Parameters

<button data-action="cart#add"
        data-cart-id-param="42"
        data-cart-quantity-param="1">追加</button>
add({ params: { id, quantity } }) {
  // id = 42, quantity = 1(型は自動変換)
}
  • イベントのターゲット要素から dataset を読む代わりに Params を使う。

7. ライフサイクルとクリーンアップ

export default class extends Controller {
  connect() {
    this.timer = setInterval(() => this.refresh(), this.intervalValue)
  }
 
  disconnect() {
    clearInterval(this.timer)
  }
}
  • connect で始めたもの(タイマー、addEventListener、Observer、サードパーティライブラリの初期化)は 必ず disconnect で後片付けする。
  • connect / disconnect は 何度も呼ばれる前提 で書く(Turbo のページ遷移や DOM の移動で再接続される)。冪等にしておく。
  • 初回だけの処理は initialize() に置く。
  • 可能な限り data-action(@window 含む)を使えば、手動の登録・解除自体が不要になる。

8. コントローラ間の連携

カスタムイベント(疎結合・推奨)

// clipboard_controller.js
copy() {
  navigator.clipboard.writeText(this.sourceTarget.value)
  this.dispatch("copied", { detail: { content: this.sourceTarget.value } })
}
<div data-controller="clipboard toast"
     data-action="clipboard:copied->toast#show">
  • this.dispatch("copied") は clipboard:copied というイベントを発火する。
  • 送信側は受信側を知らなくてよいため、最も疎結合。

Outlets(直接参照が必要な場合)

export default class extends Controller {
  static outlets = ["result"]
 
  search() {
    this.resultOutlets.forEach(r => r.clear())
  }
}
<form data-controller="search" data-search-result-outlet="#results">...</form>
<div id="results" data-controller="result">...</div>
  • DOM 上で親子関係にないコントローラを操作したいときに使う。
  • 結合度が上がるので、まずはイベントで解決できないか検討する。
  • 他コントローラのインスタンスを this.application.getControllerForElementAndIdentifier で取得するのは最終手段。

9. Turbo との併用

  • ページキャッシュに注意: Turbo はページ遷移時にDOMのスナップショットを保存する。開いたドロップダウンや一時的なクラスが残ったままキャッシュされないよう、disconnect で元に戻すか、turbo:before-cache@document アクションで後始末する。
  • サードパーティライブラリ(日付ピッカー、エディタなど)は connect で初期化し disconnect で destroy する。二重初期化に注意。
  • Turbo Stream で差し替えられた要素にも自動でコントローラが接続されるため、「ページロード時に1回だけ初期化」という発想は捨てる。

10. 登録と読み込み

importmap(Rails)

// app/javascript/controllers/index.js
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
  • 使用頻度の低い重いコントローラは lazyLoadControllersFrom で遅延読み込みを検討する。

バンドラ(esbuild / Vite など)

  • 手動登録(application.register("hello", HelloController))するか、各ツールのglob読み込みを使う。
  • 登録漏れを防ぐため、自動登録の仕組みに寄せるのがおすすめ。

開発時のデバッグ

application.debug = true // 接続・切断などがコンソールに出る

11. TypeScript を使う場合

import { Controller } from "@hotwired/stimulus"
 
export default class extends Controller<HTMLFormElement> {
  static targets = ["input"]
  static values = { url: String }
 
  declare readonly inputTarget: HTMLInputElement
  declare readonly hasInputTarget: boolean
  declare urlValue: string
}
  • declare でターゲット・値の型を宣言する。
  • Controller<HTMLFormElement> のように this.element の型も指定できる。

12. テスト

  • コントローラはDOMに依存するため、システムテスト(Capybara / Playwright など)で振る舞いを確認するのが最も実用的。
  • 単体テストを書く場合は jsdom 環境で Application.start() し、HTMLを挿入してから検証する。接続は非同期なので、DOM挿入後に1ティック待つ。
  • ロジックが複雑になったら、純粋関数としてコントローラ外に切り出して単体テストする。

13. アンチパターン集

アンチパターン改善策
document.querySelector で要素を探すTargets / Outlets を使う
クラス名・URL・数値をJSに直書きCSS Classes / Values を使う
addEventListener を書いて解除し忘れdata-action(@window 等)を使う、または disconnect で解除
状態をインスタンス変数だけで持つValues に持たせ xxxValueChanged で反映
JSでHTML文字列を大量に組み立てるサーバー描画 + Turbo Frames/Streams、または <template> 要素
1つの巨大なページ用コントローラ振る舞い単位の小さなコントローラに分割
コントローラ同士が直接メソッドを呼び合うthis.dispatch によるイベント連携
connect が2回呼ばれると壊れる冪等に書き、disconnect で確実に後片付け

14. チェックリスト

  • コントローラは1つの振る舞いに絞られているか
  • 識別子名は機能を表しているか
  • DOM参照は Targets で行っているか
  • 設定値・状態は Values に出しているか
  • クラス名は CSS Classes で受け取っているか
  • connect で始めたものを disconnect で片付けているか
  • Turbo のキャッシュ・再接続で壊れないか
  • 他コントローラとの連携はイベント優先になっているか
  • JSが無効でも最低限の機能は動くか

参考

0件のページ