0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Stimulus + MutationObserver + Turboで作る「フォーム変更の視覚的フィードバック」

0
Last updated at Posted at 2026-01-16

はじめに

管理画面やデータ編集画面で、「どこを変更したか分からなくなる」という経験はありませんか?

特に項目数が多いフォームでは、保存前に「自分が何を変えたのか」を確認したくなることがあります。本記事では、初期値から変更されたフォーム要素の背景色を変えるStimulusコントローラーを、段階的に実装していきます。

完成イメージ

  • テキストフィールドに文字を入力 → 背景がピンク色に
  • 値を元に戻す → 背景色が元に戻る
  • チェックボックスを変更 → ラベル部分の背景がピンク色に

stimulus-form-dirty-controller.png

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>

成果

  • テキストフィールドに文字を入力すると背景がピンク色になる
  • 元の値に戻すと背景色が消える

問題点

  1. checkboxとradioが動かない - valueではなくcheckedを見る必要がある
  2. selectの変更が検知されない - inputイベントではなくchangeイベントが必要
  3. 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は対象外になった

問題点

  1. checkboxに背景色を付けるとチェックマークが見えなくなる - Bootstrapなどのスタイルと干渉する
  2. 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の探し方:

  1. for属性による関連付け: <label for="input-id">
  2. 親要素が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ボタンを切り替えると、グループ全体が正しく再評価される

問題点

  1. JavaScriptで動的に追加されたフォーム要素が検知されない - 「項目を追加」ボタンで増やした入力欄など
  2. 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つ存在する

分離を選んだ理由:

  1. 使用頻度の違い: dirty機能は全ページで使いたいが、reset機能は特定の編集画面だけで使う
  2. メモリコストは軽微: 初期値のMapが2つ存在しても、要素数×数十バイト程度
  3. 依存関係の複雑さ回避: 初期値管理を共通化すると、コントローラー間の初期化順序を気にする必要が出る
  4. 単一責任の原則: 各コントローラーが1つの責務に集中できる

併用時の動作

両方のコントローラーを同じ画面で使用しても問題ありません。リセット実行時にはchangeイベントが発火するため、dirty機能も自動的に再評価されます。

リセット機能の詳細な実装については、関連記事『Stimulusで作る「フォームを初期値に戻す」リセット機能』をご覧ください。

まとめ

本記事では、Stimulusを使ってフォーム変更の視覚的フィードバックを段階的に実装しました。

Step 内容 解決した問題
1 最小限の実装 基本的な変更検知
2 checkbox/radio/select対応 異なるフォーム要素タイプへの対応
3 スタイル適用先の改善 checkbox/radioの視認性、radioグループの再評価
4 MutationObserver 動的に追加された要素への対応
5 Turbo Drive対応 SPAライクなページ遷移への対応

この実装は汎用的で、どのRailsプロジェクトにも導入できます。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?