Rails 開発者必見!Stimulus で React の useState のような動的 UI 更新を実現する方法

Scene 1: 問題発見!文字数が更新されない 😱

プロ太先生、大変です!面談記録の文字数カウントが全然更新されないんです…

どれどれ、見せてみたまえ。ああ、これはよくある問題だね。JavaScript で`getElementById`を使って実装しているが、Rails の`form_with`で生成されるフォーム要素との相性が悪いんだ。

React なら useState を使えばすぐに解決できるのに…Rails だと難しいんですか?

いや、実は Rails には**Stimulus**という素晴らしいツールがあるんだ!React の useState のような動的な更新を、Rails らしい方法で実現できるよ。

Scene 2: Stimulus って何?🤔

Stimulus…初めて聞きました!

Stimulus は Rails 標準の JavaScript フレームワークだよ。**HTML-first**の思想で、既存の HTML に JavaScript の機能を後から追加できるんだ。

Stimulus の特徴

**HTML 中心**: HTML に`data-*`属性を追加するだけ

**軽量**: React/Vue のような大きなライブラリは不要

**Rails 標準**: Turbo・Hotwire と連携して動作

**段階的導入**: 既存のプロジェクトに簡単導入

なるほど!でも具体的にはどう使うんですか?

Scene 3: 実際のコードで学ぼう!💻

OZ君の文字数カウンター問題を例に、実際のコードを見てみよう。

1.Stimulus コントローラーの作成

まず、`app/javascript/controllers/character_counter_controller.js`を作成するよ!


// Stimulusの基本クラスをインポート
import { Controller } from "@hotwired/stimulus";

// Stimulusコントローラーを定義(クラス名は自由だが、ファイル名と対応させる)
export default class extends Controller {
  // HTMLのどの要素をターゲットにするか定義
  // "input"と"counter"という名前のターゲットを設定
  static targets = ["input", "counter"];

  // HTMLから渡される値を定義
  // 最小文字数(min)と最大文字数(max)を数値として受け取る
  static values = { min: Number, max: Number };

  // コントローラーがDOMに接続された時に自動実行
  // Reactの useEffect(() => {}, []) に相当
  connect() {
    this.updateCount(); // 初期表示時に文字数を更新
  }

  // 文字数カウントを更新するメソッド
  // Reactの setState() に相当する処理
  updateCount() {
    // inputTargetから現在のテキストを取得
    const text = this.inputTarget.value;
    const count = text.length;

    // counterTargetに文字数を表示
    // ReactのuseStateで状態を更新するのと同じ効果
    this.counterTarget.textContent = count;

    // 文字数に応じてスタイルを動的に変更
    // 条件によってCSSクラスを切り替え
    if (count < this.minValue) {
      this.counterTarget.className = "text-red-600"; // 文字数不足:赤色
    } else if (count > this.maxValue) {
      this.counterTarget.className = "text-red-600"; // 文字数超過:赤色
    } else {
      this.counterTarget.className = "text-green-600"; // 適切な文字数:緑色
    }
  }
}

おお!これがコントローラーですか。React のコンポーネントみたいですね!

その通り!そして次は、HTML でこのコントローラーを使うんだ。

2. HTML ビューでの使用方法

以下が、
`app/views/auth/interview_record.html.erb`の該当部分だよ。

<!-- data-controller属性でStimulusコントローラーを指定 -->
<!-- data-*-value属性で値をJavaScriptに渡す -->
<div data-controller="character-counter"
     data-character-counter-min-value="10"
     data-character-counter-max-value="2000">

  <label class="block text-sm font-medium text-gray-700 mb-2">
    面談内容 <span class="text-red-500">*</span>
  </label>

  <!-- textareaをinputターゲットとして指定 -->
  <!-- data-action属性でイベントとメソッドを紐付け -->
  <%= form.text_area :content,
        rows: 12,
        class: "w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none",
        data: {
          character_counter_target: "input",           # inputターゲットとして指定
          action: "input->character-counter#updateCount" # inputイベント時にupdateCountメソッド実行
        } %>

  <p class="mt-2 text-sm text-gray-500">
    10文字以上2000文字以内で入力してください。
    <span class="ml-2">
      <!-- counterターゲットとして指定:ここに文字数が表示される -->
      現在: <span data-character-counter-target="counter">0</span>文字
    </span>
  </p>
</div>

なるほど!HTML の`data-*`属性で JavaScript と連携するんですね!

そうです。では、具体的にどう連携するのか、step-by-step で詳しく見てみよう。

Step 1: `data-controller` でコントローラーを接続

<!-- ① この div要素にStimulusコントローラーを接続 -->
<div data-controller="character-counter">
  <!-- この要素の中で character-counter が有効になる -->
</div>

何が起こるかの説明

– `character-counter` → `CharacterCounterController` クラスを自動で探す

– ファイル名: `character_counter_controller.js` → クラスと自動マッピング

– この div の中でコントローラーの `connect()` メソッドが実行される

Step 2: `data-*-target` で要素を指定

<div data-controller="character-counter">
  <!-- ② inputターゲットとして textarea を指定 -->
  <textarea data-character-counter-target="input"></textarea>

  <!-- ③ counterターゲットとして span を指定 -->
  <span data-character-counter-target="counter">0</span>
</div>

何が起こるかの説明

// JavaScript側で、こうアクセスできるようになる
this.inputTarget; // ← textarea要素を取得
this.counterTarget; // ← span要素を取得

// つまり、getElementById() の代わりに、こんな感じ:
// Before: document.getElementById('my-textarea')
// After:  this.inputTarget

Step 3: `data-action` でイベントを紐付け

<div data-controller="character-counter">
  <!-- ④ inputイベントが発生したらupdateCountメソッドを実行 -->
  <textarea
    data-character-counter-target="input"
    data-action="input->character-counter#updateCount"
  ></textarea>

  <span data-character-counter-target="counter">0</span>
</div>

何が起こるかの説明

– `input` = イベント名(文字入力のたび)

– `character-counter` = コントローラー名

– `updateCount` = 実行するメソッド名

– つまり: **「文字入力されたら updateCount メソッドを呼び出して」**

Step 4: `data-*-*-value` で値を渡す

<div
  data-controller="character-counter"
  data-character-counter-min-value="10"
  data-character-counter-max-value="2000"
>
  <textarea
    data-character-counter-target="input"
    data-action="input->character-counter#updateCount"
  ></textarea>

  <span data-character-counter-target="counter">0</span>
</div>

何が起こるかの説明

// JavaScript側で、こう使える
this.minValue // ← 10 が入る(Number型に自動変換)
this.maxValue // ← 2000 が入る(Number型に自動変換)

// values定義があるから自動で型変換される
static values = { min: Number, max: Number }

実際の動作フロー

つまり、どういう順番で動くんですか?

実際の動作を順番に見てみよう。

1️⃣ ページ読み込み

2️⃣ data-controller=”character-counter” を発見

3️⃣ CharacterCounterController の connect() が実行

4️⃣ this.updateCount() で初期の文字数表示

5️⃣ ユーザーがテキストエリアに文字入力

6️⃣ inputイベント発生

7️⃣ data-action で指定された updateCountメソッド実行

8️⃣ this.inputTarget.value で現在の文字を取得

9️⃣ this.counterTarget.textContent で文字数を表示更新

Stimulus の魔法のポイント

なるほど!`data-*`属性だけで、こんなに連携できるんですね!

そうなんだ!ポイントは以下の 4 つだよ。

1. **自動マッピング**: `data-controller=”character-counter”` → `CharacterCounterController`

2. **自動要素取得**: `data-*-target=”input”` → `this.inputTarget`

3. **自動イベント設定**: `data-action=”input->*#updateCount”` → 自動でイベントリスナー設定

4. **自動型変換**: `data-*-*-value=”10″` → `this.minValue` (Number 型)

**再利用性**の高さ

この`character-counter`って、他の場所でも使えるんですか?

もちろん!OZ君も既に気づいているはずだよ。モーダルでも同じコントローラーを使っているだろう?

<!-- 👍 同じコントローラーを別の場所で再利用 -->
<div data-controller="character-counter" data-character-counter-min-value="5" data-character-counter-max-value="500">
  <!-- コメント用のフォーム -->
</div>

<div data-controller="character-counter" data-character-counter-min-value="10" data-character-counter-max-value="2000">
  <!-- 面談記録用のフォーム -->
</div>

Scene 4: 実践!他の使用例

理解を深めるために、他の例も見ておこう。

例1:自動保存機能

// auto_save_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["form", "status"];
  static values = { url: String, interval: Number };

  connect() {
    this.autoSave = setInterval(() => {
      this.save();
    }, this.intervalValue);
  }

  save() {
    // フォームデータを自動保存
    this.statusTarget.textContent = "保存中...";
    // Ajax処理...
  }
}

例 2: 検索の候補表示

// search_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["input", "results"];

  search() {
    const query = this.inputTarget.value;
    if (query.length > 2) {
      // 検索API呼び出し
      this.showResults(results);
    }
  }
}

なるほど!いろんな場面で使えるんですね!

まとめ

Stimulus のポイント

1. **HTML-first**: HTML が主役、JavaScript は脇役

2. **軽量**: 必要最小限の機能で高速

3. **Rails 標準**: Turbo/Hotwire との連携が抜群

4. **再利用性**: 一度作れば色々な場所で使える

5. **学習コスト**: 基本的な JavaScript の知識があれば十分

いつ使うべき?

– ✅ **部分的な動的機能**が欲しい時

– ✅ **既存の Rails アプリ**に機能追加したい時

– ✅ **軽量な解決策**が欲しい時

– ✅ **Rails Way**に沿って開発したい時

プロ太先生、ありがとうございました!Stimulus が Rails での動的 UI 更新の強い味方だということがよく分かりました!

そうだね。React の useState のような機能を Rails らしい方法で実現できるのが Stimulus の魅力だ。これからの Rails 開発では必須のツールになるよ!