はじめに
管理画面やデータ編集画面で、「どこを変更したか分からなくなる」という経験はありませんか?
特に項目数が多いフォームでは、保存前に「自分が何を変えたのか」を確認したくなることがあります。本記事では、初期値から変更されたフォーム要素の背景色を変えるStimulusコントローラーを、段階的に実装していきます。
完成イメージ
- テキストフィールドに文字を入力 → 背景がピンク色に
- 値を元に戻す → 背景色が元に戻る
- チェックボックスを変更 → ラベル部分の背景がピンク色に
Stimulusコントローラーとは
Stimulusは、HTMLにdata-*属性を付けるだけでJavaScriptの処理を紐づけられる軽量フレームワークです。Rails 7以降のデフォルトとして採用されています。
基本的な使い方は以下の3つです:
<div data-controller="form-dirty">
<%# ↑ form_dirty_controller.js が自動的に接続される %>
<input data-action="input->form-dirty#checkDirty">
<%# ↑ inputイベント発火時に checkDirty() メソッドを呼び出す %>
<span data-form-dirty-target="indicator"></span>
<%# ↑ this.indicatorTarget でこの要素を参照できる %>
</div>
-
data-controller: コントローラー名を指定(ファイル名の_controller.jsを除いた部分) -
data-action:イベント名->コントローラー名#メソッド名でイベントハンドラを設定 -
data-xxx-target: コントローラー内からthis.xxxTargetで参照可能な要素を定義
詳細は公式ハンドブックを参照してください。
なぜこの記事でStimulusを使うのか
フォーム変更の検知は、多数のinput要素に対してイベントリスナーを設定し、状態を管理する必要があります。Stimulusを使うと:
-
スコープの自動管理:
data-controllerを付けた要素の範囲内で、子要素を自動的に管理できる - Turboとの統合: ページ遷移(Turbo Drive)後も、コントローラーが自動的に再接続される
- 宣言的な記述: どの要素にどの処理を紐づけるかが、HTMLを見るだけで把握できる
素のJavaScriptでも実装できますが、Stimulusを使うことで「どこで何が起きるか」が明確になり、保守性が向上します。
Step 1: 最小限の実装
まずは、最もシンプルな実装から始めましょう。
要件
- テキストフィールドの値が変わったら背景色を変える
- 元に戻したら背景色も戻る
どうやって実現するか
「変更された」ことを判定するには、比較対象となる初期値が必要です。ページ読み込み時にフォーム要素の値を保存しておき、ユーザーが入力するたびに現在値と比較します。
初期値の保存方法:
- JavaScriptの
Mapオブジェクトを使用 - キー: DOM要素自体(
input要素) - 値: その要素の
value
変更の検知方法:
-
inputイベントをリッスン(キー入力のたびに発火) - イベント発生時に、保存した初期値と現在の
valueを比較 - 異なれば
form-dirtyクラスを追加、同じなら削除
実装
// app/javascript/controllers/form_dirty_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
connect() {
this.initialValues = new Map();
// 全てのinput要素を取得して初期値を保存
const inputs = this.element.querySelectorAll("input, select, textarea");
inputs.forEach((input) => {
this.initialValues.set(input, input.value);
input.addEventListener("input", (e) => this.checkDirty(e));
});
}
checkDirty(event) {
const input = event.target;
const initial = this.initialValues.get(input);
if (initial !== input.value) {
input.classList.add("form-dirty");
} else {
input.classList.remove("form-dirty");
}
}
}
.form-dirty {
background-color: #ffecec !important;
}
<div data-controller="form-dirty">
<input type="text" name="name" value="初期値">
<select name="category">
<option value="1">カテゴリ1</option>
<option value="2" selected>カテゴリ2</option>
</select>
</div>
成果
- テキストフィールドに文字を入力すると背景がピンク色になる
- 元の値に戻すと背景色が消える
問題点
-
checkboxとradioが動かない -
valueではなくcheckedを見る必要がある -
selectの変更が検知されない -
inputイベントではなくchangeイベントが必要 - hidden fieldも対象になってしまう - ユーザーが操作しないフィールドは除外したい
Step 2: checkbox/radio/select対応
要件
- checkbox/radioの変更も検知する
- selectの変更も検知する
- hidden fieldは対象外にする
どうやって実現するか
checkbox/radioの値の取り方:
- checkbox/radioは
value属性ではなくchecked属性(boolean)で状態を管理している - 初期値保存時:
input.checkedを保存 - 比較時:
input.checkedと比較
selectの変更検知:
-
inputイベントはキー入力向けで、selectのドロップダウン選択では発火しないブラウザがある -
changeイベントを追加でリッスンすることで、selectの変更を確実に捕捉
hidden fieldの除外:
- セレクタに
:not([type='hidden'])を追加 - hidden fieldはフォーム送信用の値を保持するためのもので、ユーザーが直接操作しない
実装
以降のStepでは、前述と同じ部分を
// ...で省略します。完成版のコードは最後のStep 5に掲載しています。
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
connect() {
this.initialValues = new Map();
this.boundCheckDirty = this.checkDirty.bind(this); // 追加: bindしておく
const inputs = this.element.querySelectorAll(
"input:not([type='hidden']), select, textarea", // 変更: hidden除外
);
inputs.forEach((input) => {
this.saveInitialValue(input); // 変更: メソッド化
input.addEventListener("input", this.boundCheckDirty);
input.addEventListener("change", this.boundCheckDirty); // 追加: selectの変更検知用
});
}
// 追加: checkbox/radioは checked を保存
saveInitialValue(input) {
if (input.type === "checkbox" || input.type === "radio") {
this.initialValues.set(input, input.checked);
} else {
this.initialValues.set(input, input.value);
}
}
checkDirty(event) {
const input = event.target;
const initial = this.initialValues.get(input);
// 追加: checkbox/radioは checked を比較
const current =
input.type === "checkbox" || input.type === "radio"
? input.checked
: input.value;
if (initial !== current) {
input.classList.add("form-dirty");
} else {
input.classList.remove("form-dirty");
}
}
}
成果
- checkbox/radioの変更が検知される
- selectの変更も検知される
- hidden fieldは対象外になった
問題点
- checkboxに背景色を付けるとチェックマークが見えなくなる - Bootstrapなどのスタイルと干渉する
- radioボタンの動作がおかしい - 1つ選ぶと他が非選択になるが、全体を再評価していない
Step 3: checkbox/radioのスタイル適用先を改善
要件
- checkboxのチェックマークが見える状態で変更を示す
- radioボタングループ全体を正しく再評価する
どうやって実現するか
checkboxへのスタイル適用問題:
- Bootstrapなどのフレームワークでは、checkboxの見た目はCSSで描画されている
-
input要素自体に背景色を付けると、チェックマークの表示に干渉する - 解決策:
inputではなく、親要素(.form-check)やlabelに背景色を付ける
radioボタングループの問題:
- radioボタンは同じ
name属性を持つグループで排他的に動作する - 1つを選択すると、他のradioは自動的に
checked=falseになる - しかし、changeイベントは選択された要素でしか発火しない
- 解決策: radioボタンの変更時に、同じnameを持つ全要素を再評価する
labelの探し方:
-
for属性による関連付け:<label for="input-id"> - 親要素がlabelの場合:
<label><input></label>
実装
export default class extends Controller {
// connect(), saveInitialValue() は前述と同様
checkDirty(event) {
const input = event.target;
// 追加: radioボタンは同じnameを持つ全てを再評価
if (input.type === "radio" && input.name) {
const radios = this.element.querySelectorAll(
`input[type="radio"][name="${input.name}"]`,
);
radios.forEach((radio) => this.updateDirtyState(radio));
} else {
this.updateDirtyState(input);
}
}
// 追加: 状態判定とスタイル適用を分離
updateDirtyState(input) {
const initial = this.initialValues.get(input);
const current =
input.type === "checkbox" || input.type === "radio"
? input.checked
: input.value;
if (initial !== current) {
this.applyDirtyStyle(input);
} else {
this.removeDirtyStyle(input);
}
}
// 追加: checkbox/radioはラッパーやlabelにスタイル適用
applyDirtyStyle(input) {
if (input.type === "checkbox" || input.type === "radio") {
// Bootstrapの.form-checkラッパー、またはlabelに適用
const wrapper = input.closest(".form-check");
if (wrapper) {
wrapper.classList.add("form-dirty");
} else {
const label = this.findLabelFor(input);
if (label) label.classList.add("form-dirty");
}
} else {
input.classList.add("form-dirty");
}
}
// 追加: applyDirtyStyleの逆
removeDirtyStyle(input) {
if (input.type === "checkbox" || input.type === "radio") {
const wrapper = input.closest(".form-check");
if (wrapper) {
wrapper.classList.remove("form-dirty");
} else {
const label = this.findLabelFor(input);
if (label) label.classList.remove("form-dirty");
}
} else {
input.classList.remove("form-dirty");
}
}
// 追加: labelの探索(for属性 or 親要素)
findLabelFor(input) {
if (input.id) {
const label = document.querySelector(`label[for="${input.id}"]`);
if (label) return label;
}
return input.closest("label");
}
}
.form-dirty {
background-color: #ffecec !important;
}
/* checkbox/radioのラッパー用 */
.form-check.form-dirty,
label.form-dirty {
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
}
成果
- checkboxのチェックマークが見える状態で背景色が付く
- radioボタンを切り替えると、グループ全体が正しく再評価される
問題点
- JavaScriptで動的に追加されたフォーム要素が検知されない - 「項目を追加」ボタンで増やした入力欄など
-
Turbo Driveでページ遷移すると動作しない - bodyが保持されて
connect()が再実行されない
Step 4: MutationObserverで動的要素に対応
要件
- JavaScriptで後から追加されたフォーム要素も監視する
MutationObserverとは
MutationObserverは、DOMツリーの変更を監視するためのブラウザ標準APIです。
従来の方法(DOMイベント DOMNodeInserted 等)は非推奨となり、現在はMutationObserverが推奨されています。主な特徴は:
- 非同期バッチ処理: 複数の変更をまとめてコールバックに渡すため、パフォーマンスが良い
- 柔軟な監視対象: 子要素の追加・削除、属性の変更、テキストの変更など、監視対象を細かく指定できる
- 全モダンブラウザ対応: IE11を含む全ての主要ブラウザでサポート
基本的な使い方:
// 1. コールバック関数を渡してインスタンス作成
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
// mutation.addedNodes: 追加されたノード
// mutation.removedNodes: 削除されたノード
});
});
// 2. 監視対象と監視オプションを指定して開始
observer.observe(targetElement, {
childList: true, // 子要素の追加・削除を監視
subtree: true, // 子孫要素も再帰的に監視
});
// 3. 不要になったら監視を停止
observer.disconnect();
どうやって実現するか
MutationObserverの設定:
-
childList: true- 子要素の追加・削除を監視 -
subtree: true- 子孫要素も再帰的に監視 -
attributesは監視しない - パフォーマンスのため
二重登録の防止:
- 既に
initialValuesに登録済みの要素はスキップ -
this.initialValues.has(input)でチェック
実装
export default class extends Controller {
connect() {
this.initialValues = new Map();
this.boundCheckDirty = this.checkDirty.bind(this);
// 変更: 初期登録をメソッド化
this.registerInputsIn(this.element);
// 追加: DOM変更を監視(動的に追加されたフォーム要素に対応)
this.observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
this.registerInputsIn(node);
}
});
});
});
this.observer.observe(this.element, { childList: true, subtree: true });
}
// 追加: クリーンアップ
disconnect() {
this.observer?.disconnect();
}
// 追加: 指定要素内のフォーム要素を登録(二重登録防止つき)
registerInputsIn(container) {
const inputs = container.querySelectorAll(
"input:not([type='hidden']), select, textarea",
);
inputs.forEach((input) => {
if (this.initialValues.has(input)) return; // 既に登録済みならスキップ
this.saveInitialValue(input);
input.addEventListener("input", this.boundCheckDirty);
input.addEventListener("change", this.boundCheckDirty);
});
}
// saveInitialValue(), checkDirty(), updateDirtyState(),
// applyDirtyStyle(), removeDirtyStyle(), findLabelFor() は前述と同様
}
成果
- JavaScriptで動的に追加されたフォーム要素も自動的に監視される
- 「項目を追加」ボタンで増やした入力欄も変更検知が動作する
問題点
- Turbo Driveでページ遷移すると、古い初期値のまま判定されてしまう
Step 5: Turbo Drive対応(完成版)
要件
- Turbo Driveでページ遷移しても正しく動作する
どうやって実現するか
Turbo Driveの動作:
- Turbo Driveは
<body>要素を保持したままコンテンツを差し替える - そのため、
bodyに配置したコントローラーのconnect()が再実行されない - 結果として、古いページの初期値が残ったまま新しいページを判定してしまう
解決策:
-
turbo:loadイベントをリッスン - このイベントはTurboでページが読み込まれるたびに発火する
- イベント発生時に
initialValuesをクリアし、新しいページの値で再登録
イベントリスナーのクリーンアップ:
-
disconnect()でイベントリスナーを削除 - メモリリークを防ぐため必須
完成版の実装
// app/javascript/controllers/form_dirty_controller.js
import { Controller } from "@hotwired/stimulus";
// フォーム要素の変更を検知し、初期値から変更があれば背景色を変更するコントローラー
// body要素に配置することで、ページ内の全フォーム要素を自動的に監視する
export default class extends Controller {
connect() {
this.initialValues = new Map();
this.boundCheckDirty = this.checkDirty.bind(this);
// 既存のフォーム要素を登録
this.registerInputsIn(this.element);
// Turbo対応:ページ遷移時に初期値を再保存
this.boundReinitialize = this.reinitialize.bind(this);
document.addEventListener("turbo:load", this.boundReinitialize);
// DOM変更を監視(動的に追加されたフォーム要素に対応)
this.observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
this.registerInputsIn(node);
}
});
});
});
this.observer.observe(this.element, { childList: true, subtree: true });
}
disconnect() {
this.observer?.disconnect();
document.removeEventListener("turbo:load", this.boundReinitialize);
}
reinitialize() {
this.initialValues.clear();
this.registerInputsIn(this.element);
}
registerInputsIn(container) {
const inputs = container.querySelectorAll(
"input:not([type='hidden']), select, textarea",
);
inputs.forEach((input) => {
if (this.initialValues.has(input)) return;
this.saveInitialValue(input);
input.addEventListener("input", this.boundCheckDirty);
input.addEventListener("change", this.boundCheckDirty);
});
}
saveInitialValue(input) {
if (input.type === "checkbox" || input.type === "radio") {
this.initialValues.set(input, input.checked);
} else {
this.initialValues.set(input, input.value);
}
}
checkDirty(event) {
const input = event.target;
if (input.type === "radio" && input.name) {
const radios = this.element.querySelectorAll(
`input[type="radio"][name="${input.name}"]`,
);
radios.forEach((radio) => this.updateDirtyState(radio));
} else {
this.updateDirtyState(input);
}
}
updateDirtyState(input) {
if (input.disabled) {
this.removeDirtyStyle(input);
return;
}
const initial = this.initialValues.get(input);
const current =
input.type === "checkbox" || input.type === "radio"
? input.checked
: input.value;
if (initial !== current) {
this.applyDirtyStyle(input);
} else {
this.removeDirtyStyle(input);
}
}
applyDirtyStyle(input) {
if (input.type === "checkbox" || input.type === "radio") {
const wrapper = input.closest(".form-check");
if (wrapper) {
wrapper.classList.add("form-dirty");
} else {
const label = this.findLabelFor(input);
if (label) label.classList.add("form-dirty");
}
} else {
input.classList.add("form-dirty");
}
}
removeDirtyStyle(input) {
if (input.type === "checkbox" || input.type === "radio") {
const wrapper = input.closest(".form-check");
if (wrapper) {
wrapper.classList.remove("form-dirty");
} else {
const label = this.findLabelFor(input);
if (label) label.classList.remove("form-dirty");
}
} else {
input.classList.remove("form-dirty");
}
}
findLabelFor(input) {
if (input.id) {
const label = document.querySelector(`label[for="${input.id}"]`);
if (label) return label;
}
return input.closest("label");
}
}
CSS
/* フォーム変更時の視覚的フィードバック(薄いピンク色) */
.form-dirty {
background-color: #ffecec !important;
}
/* checkbox/radioのラッパー用 */
.form-check.form-dirty,
label.form-dirty {
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
}
レイアウトへの適用
body要素にコントローラーを適用することで、全ページで有効になります。
<body data-controller="form-dirty">
<%= yield %>
</body>
最終的な成果
- テキスト/select/textareaの変更を検知
- checkbox/radioの変更を検知(ラッパーに背景色)
- 動的に追加されたフォーム要素にも対応
- Turbo Driveでのページ遷移にも対応
- disabledの要素は対象外
関連機能:フォームリセット機能との関係
本記事の「変更検知」機能と、「フォームを初期値に戻す」リセット機能は、どちらも初期値をMapで保持するという共通点があります。
しかし、実装時の検討の結果、これらは独立したコントローラーとして分離しました。
なぜ分離したのか
| 選択肢 | メリット | デメリット |
|---|---|---|
| 1つのコントローラーに統合 | 初期値のMapが1つで済む | dirty機能だけ使いたいページでもreset機能のコードが読み込まれる |
| 独立したコントローラーに分離 | 単機能で使いやすい、必要な機能だけ導入できる | 両方使う場合、初期値のMapが2つ存在する |
分離を選んだ理由:
- 使用頻度の違い: dirty機能は全ページで使いたいが、reset機能は特定の編集画面だけで使う
- メモリコストは軽微: 初期値のMapが2つ存在しても、要素数×数十バイト程度
- 依存関係の複雑さ回避: 初期値管理を共通化すると、コントローラー間の初期化順序を気にする必要が出る
- 単一責任の原則: 各コントローラーが1つの責務に集中できる
併用時の動作
両方のコントローラーを同じ画面で使用しても問題ありません。リセット実行時にはchangeイベントが発火するため、dirty機能も自動的に再評価されます。
リセット機能の詳細な実装については、関連記事『Stimulusで作る「フォームを初期値に戻す」リセット機能』をご覧ください。
まとめ
本記事では、Stimulusを使ってフォーム変更の視覚的フィードバックを段階的に実装しました。
| Step | 内容 | 解決した問題 |
|---|---|---|
| 1 | 最小限の実装 | 基本的な変更検知 |
| 2 | checkbox/radio/select対応 | 異なるフォーム要素タイプへの対応 |
| 3 | スタイル適用先の改善 | checkbox/radioの視認性、radioグループの再評価 |
| 4 | MutationObserver | 動的に追加された要素への対応 |
| 5 | Turbo Drive対応 | SPAライクなページ遷移への対応 |
この実装は汎用的で、どのRailsプロジェクトにも導入できます。
