Stimulus(Hotwire)は「HTMLが主役、JavaScriptは振る舞いを添えるだけ」という思想のフレームワークです。本ドキュメントでは Stimulus 3.x を前提に、保守しやすいコントローラを書くための指針をまとめます。
目次
- 基本思想を押さえる
- コントローラ設計
- Targets
- Values(状態と設定)
- CSS Classes
- Actions
- ライフサイクルとクリーンアップ
- コントローラ間の連携
- Turbo との併用
- 登録と読み込み
- TypeScript を使う場合
- テスト
- アンチパターン集
- チェックリスト
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.js | hello |
date_picker_controller.js | date-picker |
users/list_item_controller.js | users--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>| オプション | 効果 |
|---|---|
:prevent | preventDefault() |
:stop | stopPropagation() |
:self | イベントのターゲットが要素自身の時だけ発火 |
:once | 1回だけ発火 |
: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が無効でも最低限の機能は動くか