はじめに
管理画面でデータを編集中、「やっぱり元に戻したい」と思うことはありませんか?
ブラウザの「フォームリセット」機能は、HTMLの初期値(value属性)に戻すものであり、サーバーから取得した値に戻すわけではありません。本記事では、ページ表示時の値(サーバーから取得した値)に戻すStimulusコントローラーを、段階的に実装していきます。
完成イメージ
- 「リセット」ボタンをクリック → 確認ダイアログ表示
- 「OK」を押すと、該当項目がページ表示時の値に戻る
- グループ単位でリセット対象を絞り込める
Stimulusコントローラーとは
Stimulusは、HTMLにdata-*属性を付けるだけでJavaScriptの処理を紐づけられる軽量フレームワークです。Rails 7以降のデフォルトとして採用されています。
基本的な使い方は以下の3つです:
<div data-controller="form-reset">
<%# ↑ form_reset_controller.js が自動的に接続される %>
<button data-action="click->form-reset#reset">リセット</button>
<%# ↑ クリック時に reset() メソッドを呼び出す %>
<input data-form-reset-target="input">
<%# ↑ this.inputTargets でこの要素を参照できる %>
</div>
-
data-controller: コントローラー名を指定(ファイル名の_controller.jsを除いた部分) -
data-action:イベント名->コントローラー名#メソッド名でイベントハンドラを設定 -
data-xxx-target: コントローラー内からthis.xxxTargetで参照可能な要素を定義
詳細は公式ハンドブックを参照してください。
なぜこの記事でStimulusを使うのか
リセット機能は「ボタンをクリック → 対象要素の値を初期値に戻す」という明確なアクションです。Stimulusを使うと:
-
アクションの宣言:
data-action="click->form-reset#reset"で、ボタンと処理の紐づけがHTMLで完結 -
ターゲットの指定:
data-form-reset-target="input"で、リセット対象の要素を明示的に選択できる -
グループ化が容易: 後述するように、
data-form-reset-group-valueで対象を絞り込む拡張も自然に実装できる
素のJavaScriptでも実装できますが、Stimulusを使うことで「どのボタンが何をリセットするか」がHTMLを見るだけで把握でき、保守性が向上します。
Step 1: 最小限の実装
まずは、最もシンプルな実装から始めましょう。
要件
- リセットボタンを押すと、フォームの値がページ表示時の値に戻る
どうやって実現するか
「元に戻す」ためには、比較対象となる初期値をあらかじめ保存しておく必要があります。
初期値の保存方法:
- JavaScriptの
Mapオブジェクトを使用 - キー: DOM要素自体(
input要素) - 値: その要素の
value - Stimulusの
connect()(コントローラーがDOMに接続されたタイミング)で保存
リセット対象の指定方法:
- Stimulus Targetsを使用(
data-form-reset-target="input") - これにより「リセット対象にしたい要素」を明示的に指定できる
- 全ての要素を対象にするのではなく、必要な要素だけを登録
リセットの実行:
- 保存した初期値を各要素の
valueに再代入
実装
// app/javascript/controllers/form_reset_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["input"];
connect() {
this.initialValues = new Map();
this.inputTargets.forEach((input) => {
this.initialValues.set(input, input.value);
});
}
reset() {
this.inputTargets.forEach((input) => {
input.value = this.initialValues.get(input);
});
}
}
<div data-controller="form-reset">
<input type="text" name="name" value="初期値" data-form-reset-target="input">
<button type="button" data-action="form-reset#reset">リセット</button>
</div>
成果
- リセットボタンを押すと、テキストフィールドがページ表示時の値に戻る
問題点
-
checkbox/radioが動かない -
valueではなくcheckedを見る必要がある - 確認なしでリセットされてしまう - 誤操作で編集内容が消える危険
- 全項目が一括でリセットされる - 「この項目だけ戻したい」ができない
Step 2: checkbox/radio対応と確認ダイアログ
要件
- checkbox/radioもリセットできるようにする
- 誤操作防止のため、リセット前に確認ダイアログを表示する
どうやって実現するか
checkbox/radioの値の取り方:
- checkbox/radioは
value属性ではなくchecked属性(boolean)で状態を管理している - 初期値保存時:
input.checkedを保存 - 復元時:
input.checkedに代入
確認ダイアログ:
-
window.confirm()を使用(ブラウザ標準のダイアログ) - 「キャンセル」が押されたら
returnで処理を中断
実装
以降のStepでは、前述と同じ部分を
// ...で省略します。完成版のコードは最後のStep 5に掲載しています。
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["input"];
connect() {
this.initialValues = new Map();
this.inputTargets.forEach((input) => {
this.saveInitialValue(input); // 変更: メソッド化
});
}
// 追加: 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);
}
}
reset(event) {
event.preventDefault(); // 追加
// 追加: 確認ダイアログ
if (!confirm("初期状態に戻しますか?")) {
return;
}
this.inputTargets.forEach((input) => {
this.restoreInitialValue(input); // 変更: メソッド化
});
}
// 追加: checkbox/radioは checked を復元
restoreInitialValue(input) {
const initialValue = this.initialValues.get(input);
if (initialValue === undefined) return;
if (input.type === "checkbox" || input.type === "radio") {
input.checked = initialValue;
} else {
input.value = initialValue;
}
}
}
成果
- checkbox/radioもリセットできる
- 確認ダイアログが表示され、誤操作を防げる
問題点
- 確認ダイアログのメッセージが固定 - 「商品情報を初期状態に戻しますか?」のようにカスタマイズしたい
- 全項目が一括でリセットされる - 「住所だけ戻したい」ができない
Step 3: Stimulus Paramsで確認メッセージをカスタマイズ
要件
- ボタンごとに異なる確認メッセージを表示できるようにする
どうやって実現するか
Stimulus Paramsの仕組み:
- ボタン要素に
data-{controller}-{param-name}-param属性を付ける - アクションメソッド内で
event.params.paramNameとして取得できる - これにより、同じアクションでもボタンごとに異なるパラメータを渡せる
パラメータ名の命名規則:
- HTML属性:
data-form-reset-target-name-param(ケバブケース) - JavaScript:
event.params.targetName(キャメルケース) - Stimulusが自動的に変換してくれる
実装
export default class extends Controller {
static targets = ["input"];
// connect(), saveInitialValue(), restoreInitialValue() は前述と同様
reset(event) {
event.preventDefault();
// 追加: Stimulus Paramsでメッセージをカスタマイズ
const targetName = event.params.targetName || "この項目";
if (!confirm(`「${targetName}」を初期状態に戻しますか?`)) {
return;
}
this.inputTargets.forEach((input) => {
this.restoreInitialValue(input);
});
}
}
<div data-controller="form-reset">
<input type="text" name="name" data-form-reset-target="input">
<button type="button"
data-action="form-reset#reset"
data-form-reset-target-name-param="商品名">
<%# ↑ 追加: パラメータでメッセージをカスタマイズ %>
リセット
</button>
</div>
これで「「商品名」を初期状態に戻しますか?」と表示されます。
成果
- ボタンごとにメッセージをカスタマイズできる
- ユーザーにとって分かりやすい確認ダイアログになった
問題点
- まだ全項目が一括でリセットされる - グループ単位でリセットしたい
Step 4: グループ単位でのリセット
要件
- 「基本情報」だけ、「詳細情報」だけ、といった部分リセットを可能にする
どうやって実現するか
グループの指定方法:
- 各フォーム要素に
data-form-reset-group属性を付ける - 例:
data-form-reset-group="basic" - リセットボタンにも
data-form-reset-group-paramでグループ名を指定
フィルタリングのロジック:
- ボタンからグループ名を取得(
event.params.group) - 各要素のグループ名と比較(
input.dataset.formResetGroup) - グループが一致する要素のみリセット
- グループ指定がないボタンは全要素をリセット(従来の動作)
実装
export default class extends Controller {
static targets = ["input"];
// connect(), saveInitialValue(), restoreInitialValue() は前述と同様
reset(event) {
event.preventDefault();
const group = event.params.group; // 追加: グループ名を取得
const targetName = event.params.targetName || "この項目";
if (!confirm(`「${targetName}」を初期状態に戻しますか?`)) {
return;
}
this.inputTargets.forEach((input) => {
// 追加: グループが指定されている場合、該当グループのみリセット
if (group && input.dataset.formResetGroup !== group) {
return; // グループが違えばスキップ
}
this.restoreInitialValue(input);
});
}
}
<div data-controller="form-reset">
<!-- 基本情報グループ -->
<fieldset>
<legend>基本情報</legend>
<input type="text" name="name"
data-form-reset-target="input"
data-form-reset-group="basic">
<input type="number" name="price"
data-form-reset-target="input"
data-form-reset-group="basic">
<button type="button"
data-action="form-reset#reset"
data-form-reset-group-param="basic"
data-form-reset-target-name-param="基本情報">
基本情報をリセット
</button>
</fieldset>
<!-- 詳細情報グループ -->
<fieldset>
<legend>詳細情報</legend>
<textarea name="description"
data-form-reset-target="input"
data-form-reset-group="detail"></textarea>
<button type="button"
data-action="form-reset#reset"
data-form-reset-group-param="detail"
data-form-reset-target-name-param="詳細情報">
詳細情報をリセット
</button>
</fieldset>
<!-- 全体リセット(グループ指定なし) -->
<button type="button"
data-action="form-reset#reset"
data-form-reset-target-name-param="全ての項目">
全てリセット
</button>
</div>
成果
- 「基本情報」だけ、「詳細情報」だけ、といった部分リセットができる
- グループ指定なしのボタンで全体リセットも可能
問題点
- 他のコントローラーがリセットを検知できない - 例えば「変更検知機能」と連携したい
Step 5: カスタムイベントで他コントローラーと連携(完成版)
要件
- リセット実行を他のコントローラーに通知する
- 例: 変更検知機能(form-dirty)がリセット後に背景色を更新できるようにする
どうやって実現するか
changeイベントの発火:
- 値を復元しただけでは、ブラウザはイベントを発火しない
-
input.dispatchEvent(new Event("change", { bubbles: true }))で手動発火 - これにより、変更検知機能(form-dirty)が再評価される
カスタムイベントの発火:
- リセット完了後に
form-reset:completedイベントを発火 -
CustomEventを使用し、detailにグループ名などの情報を含める -
bubbles: trueでイベントをバブリングさせ、親要素でもキャッチ可能に
カスタムイベントを使う理由:
- 各要素のchangeイベントだけでは「リセットが完了した」というタイミングを知れない
- まとめて何か処理をしたい場合に有用
完成版の実装
// app/javascript/controllers/form_reset_controller.js
import { Controller } from "@hotwired/stimulus";
// フォームを初期状態(ページ表示時の値)に戻すコントローラー
// グループ属性でフィルタリングしてリセット対象を絞り込める
export default class extends Controller {
static targets = ["input"];
connect() {
this.initialValues = new Map();
this.inputTargets.forEach((input) => {
this.saveInitialValue(input);
});
}
saveInitialValue(input) {
if (input.type === "checkbox" || input.type === "radio") {
this.initialValues.set(input, input.checked);
} else {
this.initialValues.set(input, input.value);
}
}
reset(event) {
event.preventDefault();
const group = event.params.group;
const targetName = event.params.targetName || "この項目";
if (!confirm(`「${targetName}」を初期状態に戻しますか?`)) {
return;
}
this.inputTargets.forEach((input) => {
if (group && input.dataset.formResetGroup !== group) {
return;
}
this.restoreInitialValue(input);
});
// リセット完了後にカスタムイベントを発火(他コントローラー連携用)
this.element.dispatchEvent(
new CustomEvent("form-reset:completed", {
detail: { group },
bubbles: true,
}),
);
}
restoreInitialValue(input) {
const initialValue = this.initialValues.get(input);
if (initialValue === undefined) return;
if (input.type === "checkbox" || input.type === "radio") {
input.checked = initialValue;
} else {
input.value = initialValue;
}
// changeイベントを発火して他のコントローラーに通知
input.dispatchEvent(new Event("change", { bubbles: true }));
}
}
changeイベント発火の効果
// 値を復元した後、changeイベントを発火
input.dispatchEvent(new Event("change", { bubbles: true }));
これにより:
- 変更検知機能(form-dirty)が再評価される → リセット後に背景色が元に戻る
- フォームバリデーションがトリガーされる
- 依存関係のある他のフィールドが更新される
カスタムイベントの活用例
// 別のコントローラーや別の箇所でリセット完了を検知
document.addEventListener("form-reset:completed", (event) => {
console.log(`グループ ${event.detail.group} がリセットされました`);
// 追加の処理...
});
最終的な成果
- ページ表示時の値にリセット(HTMLの初期値ではない)
- checkbox/radioも正しくリセット
- 確認ダイアログ付き(メッセージカスタマイズ可能)
- グループ単位でのリセット
- 他コントローラーとの連携(changeイベント、カスタムイベント)
関連機能:変更検知機能との関係
本記事の「リセット」機能と、「変更を視覚的に表示する」dirty機能は、どちらも初期値をMapで保持するという共通点があります。
しかし、実装時の検討の結果、これらは独立したコントローラーとして分離しました。
なぜ分離したのか
| 選択肢 | メリット | デメリット |
|---|---|---|
| 1つのコントローラーに統合 | 初期値のMapが1つで済む | reset機能だけ使いたいページでもdirty機能のコードが読み込まれる |
| 独立したコントローラーに分離 | 単機能で使いやすい、必要な機能だけ導入できる | 両方使う場合、初期値のMapが2つ存在する |
分離を選んだ理由:
- 使用頻度の違い: dirty機能は全ページで使いたいが、reset機能は特定の編集画面だけで使う
- メモリコストは軽微: 初期値のMapが2つ存在しても、要素数×数十バイト程度
- 依存関係の複雑さ回避: 初期値管理を共通化すると、コントローラー間の初期化順序を気にする必要が出る
- 単一責任の原則: 各コントローラーが1つの責務に集中できる
併用時の動作
両方のコントローラーを同じ画面で使用しても問題ありません。
<body data-controller="form-dirty">
<div data-controller="form-reset">
<input type="text"
data-form-reset-target="input"
name="product[name]">
<button data-action="form-reset#reset">リセット</button>
</div>
</body>
リセット実行時には各入力要素でchangeイベントが発火するため、dirty機能も自動的に再評価されます。つまり、リセット後は背景色が元に戻ります。
変更検知機能の詳細な実装については、関連記事『Stimulus + MutationObserver + Turboで作る「フォーム変更の視覚的フィードバック」』をご覧ください。
まとめ
本記事では、Stimulusを使ってフォームを初期値に戻すリセット機能を段階的に実装しました。
| Step | 内容 | 解決した問題 |
|---|---|---|
| 1 | 最小限の実装 | 基本的なリセット |
| 2 | checkbox/radio対応、確認ダイアログ | 異なるフォーム要素タイプ、誤操作防止 |
| 3 | Stimulus Params | 確認メッセージのカスタマイズ |
| 4 | グループ単位リセット | 部分的なリセット |
| 5 | イベント発火 | 他コントローラーとの連携 |
この実装は汎用的で、どのRailsプロジェクトにも導入できます。
