Ruby on Rails 8におけるHotwireを使ったリアルタイムWebアプリケーション構築:TurboとStimulusの実践ガイド

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

アーキテクチャの対決:重厚なSPA vs. Hotwireアプローチ

過去10年以上にわたり、フロントエンド開発の定石とされてきたのは、WebプロジェクトをバックエンドのJSON APIとReact、Vue、Angularなどで構築されたクライアントサイドのシングルページアプリケーション(SPA)という2つの独立したコードベースに分割することでした。このアーキテクチャは、専任のプラットフォームチームを抱える50人規模のエンジニア組織にとっては理にかなっています。

しかし、少人数のチームにとっては過度なオーバーヘッドを生み出します。モデルを重複して定義し、二重のバリデーション層を維持し、クロスオリジンでのトークン交換をデバッグし、WebpackやViteのバージョン更新のたびに壊れるビルドパイプラインの管理に追われることになります。

Hotwire(HTML Over The Wire)はその対極のアプローチをとります。クライアント側のJavaScriptエンジンがDOM(Document Object Model)を構築するための生のJSONペイロードを送信する代わりに、サーバー側で事前レンダリングされたHTMLフラグメントをネットワーク経由で直接送信します。Rails 8は、Hotwireをネイティブの基盤として採用し、主に以下の4つのコアレイヤーで構成されています。

  • Turbo Drive: リンクのクリックやフォーム送信をインターセプトし、ドキュメント全体を再読み込みすることなく<body>を差し替えます。
  • Turbo Frames: ページを独立したコンテキストに分割し、周囲のマークアップに影響を与えることなく特定の領域のみを更新します。
  • Turbo Streams: 標準のHTMLパーシャルを使用し、WebSocketsまたはHTTPレスポンス経由でDOMの精密な変更(append、prepend、update、replace、remove)を適用します。
  • Stimulus: クライアント側の状態ツリーを介さず、HTMLの属性にスコープを持った振る舞いを直接アタッチする軽量JavaScriptフレームワークです。

Rails 8では、Solid Cableの導入により、リアルタイム配信がさらにシンプルになりました。このデータベースバックエンドのAction Cableアダプターは、SQLite、PostgreSQL、MySQL経由でPub/Subメッセージングを処理するため、これまで必須とされていた専用のRedisサーバーが不要になります。

Hotwireの評価:強みと現実的なトレードオフ

Hotwireは万能薬ではありません。その得意領域と技術的な限界を理解しておくことで、プロジェクト途中で発生する苦痛な再実装を防ぐことができます。

Hotwireが優れている点

  • 統合されたメンタルモデル: ビジネスロジック、ドメインモデル、バリデーション、ルーティングが1か所に集約されます。標準的なERBテンプレートでマークアップを一度書くだけで済みます。
  • 軽量なペイロード: 事前レンダリングされたHTMLを送信することで、一般的なReactランタイムの約1.2 MBに及ぶ初期アセットバンドルを、圧縮後45 kB未満のHotwire JavaScriptにまで削減できます。スペックの低いモバイルデバイスでも、スクリプトの解析にかかる時間を大幅に短縮できます。
  • ボイラープレートの少ないWebSockets: モデルのコールバックを1行記述するだけで、同時に接続している数百のブラウザタブにレコードの更新をブロードキャストできます。
  • 機能開発スピードの向上: TypeScriptの型定義やRedux sliceを二重に作成する手間が省けるため、管理用のCRUD画面やアクティビティフィードを数日ではなく数時間でリリースできます。

クライアントサイドSPAに依然として分がある点

  • オフラインファーストのワークフロー: 現場検査アプリやベクター描画ツールのように、ネットワーク接続がない環境でも全機能が動作する必要がある場合は、IndexedDBキャッシュを備えたクライアントサイドフレームワークを採用するのが適切です。
  • 高頻度のインタラクション: 60 fpsのドラッグ&ドロップ操作、Canvasの描画処理、オーディオタイムラインのスクラブなどを必要とするインターフェースには、依然として専用のクライアント側ステートマネージャーが求められます。
  • 高負荷時のサーバーコンピュート負荷: HTMLパーシャルのレンダリングは、生のJSON文字列を出力するよりも多くのサーバーCPUサイクルを消費します。毎秒数万リクエストをさばく規模では、より多くのサーバーリソースを割り当てる必要があります。

Rails 8でリアルタイムHotwireを設定する

Rails 8を新規インストールすると、Hotwireが標準でバンドルされ、Solid Cableも自動的に構成されます。ローカルでWebSocketsを動かすためだけにRedisをインストールしたり、バックグラウンドのサイドカーサービスを管理したりする必要はありません。

ステップ1: アプリケーションの作成

rails new live_pulse --database=sqlite3
cd live_pulse

Rails 8ではデフォルトでSQLiteがWAL(Write-Ahead Logging)モードで動作するため、追加設定なしで同時読み書きがサポートされます。config/cable.ymlファイルを開いて、アダプターの設定を確認してください。

development:
  adapter: solid_cable
  connect_path: "/cable"

test:
  adapter: test

production:
  adapter: solid_cable
  connect_path: "/cable"

ステップ2: 依存関係の確認

Gemfileを確認します。HotwireはRails 8にデフォルトで含まれている以下の3つのGemに依存しています。

gem "turbo-rails"
gem "stimulus-rails"
gem "solid_cable"

既存の古いRailsアプリケーションをアップグレードする場合は、以下のインストールスクリプトを実行して依存関係をバンドルし、Import Mapを構成してください。

bin/rails hotwire:install

ステップバイステップの実装:リアルタイムフィードの構築

エンジニアがアナウンスを投稿すると、手動でページをリロードすることなく接続中のすべての画面に即座に表示される、共同作業用のお知らせ掲示板を作成してみましょう。

ステップ1: Noticeリソースの生成

bin/rails generate scaffold Notice title:string content:text
bin/rails db:migrate

config/routes.rbを編集して、アクセス時にお知らせ一覧が表示されるようにルーティングを設定します。

Rails.application.routes.draw do
  resources :notices
  root "notices#index"
end

ステップ2: モデルブロードキャストの設定

app/models/notice.rbを開きます。Hotwireを使うと、ActiveRecordのライフサイクルイベントをAction Cableの配信処理に直接連携させることができます。

class Notice < ApplicationRecord
  validates :title, presence: true
  validates :content, presence: true

  broadcasts_to ->(notice) { "notices" }, inserts_by: :prepend
end

このbroadcasts_to宣言を1行追加するだけで、以下の3つの操作が自動化されます。

  1. 作成(Create): レコードが挿入されると、Railsは_notice.html.erbをレンダリングし、現在接続しているすべての購読者のid="notices"と一致するコンテナの先頭にマークアップを追加(prepend)します。
  2. 更新(Update): お知らせが変更されて保存されると、Railsはパーシャルを再レンダリングし、該当するDOM IDを持つ既存の要素を置換(replace)します。
  3. 削除(Destroy): レコードが削除されると、RailsはDOMからその特定のノードを削除(remove)する指示をブロードキャストします。

ステップ3: ビューでリアルタイムストリームを購読する

app/views/notices/index.html.erbを開き、内容を以下のレイアウトに置き換えます。

<%= turbo_stream_from "notices" %>

<div class="header">
  <h1>リアルタイムお知らせ掲示板</h1>
</div>

<%= turbo_frame_tag "new_notice_frame" do %>
  <div class="form-container">
    <h3>お知らせを投稿</h3>
    <%= render "form", notice: Notice.new %>
  </div>
<% end %>

<div id="notices" class="notices-list">
  <%= render @notices %>
</div>

ここで重要な役割を担うのがturbo_stream_from "notices"ヘルパーです。Solid Cableを介してWebSocketチャネルを確立し、モデルが変更をブロードキャストするたびに、ブラウザは受信したHTMLスニペットをドキュメントへ即座に反映します。

ステップ4: パーシャルに安定したDOM IDを付与する

Turbo StreamsはIDを使って要素を特定し、置換や削除を行います。app/views/notices/_notice.html.erbを更新し、Rails組み込みのdom_idヘルパーを利用するようにします。

<div id="<%= dom_id notice %>" class="notice-card">
  <h4><%= notice.title %></h4>
  <p><%= notice.content %></p>
  <small>投稿日時: <%= time_ago_in_words(notice.created_at) %>前</small>
  <div class="actions">
    <%= link_to "編集", edit_notice_path(notice) %>
    <%= button_to "削除", notice_path(notice), method: :delete %>
  </div>
</div>

ステップ5: Stimulusでフォーム入力をリセットする

Turbo DriveはAJAX経由でフォームを送信するため、ページ全体のリロードは発生しません。その結果、送信が成功した後もテキスト入力欄に値が残ったままになります。わずか5行のStimulusコントローラーでこの挙動を修正できます。

コントローラーの雛形を生成します。

bin/rails generate stimulus reset-form

次にapp/javascript/controllers/reset_form_controller.jsを編集します。バリデーションエラー発生時に入力内容が消えてしまわないよう、必ずevent.detail.successをチェックしてください。

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  reset(event) {
    if (event.detail.success) {
      this.element.reset()
    }
  }
}

app/views/notices/_form.html.erb内のフォーム要素にコントローラーを紐付けます。

<%= form_with(model: notice, data: { controller: "reset-form", action: "turbo:submit-end->reset-form#reset" }) do |form| %>
  <% if notice.errors.any? %>
    <div class="errors">
      <ul>
        <% notice.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= form.label :title %>
    <%= form.text_field :title %>
  </div>

  <div>
    <%= form.label :content %>
    <%= form.text_area :content %>
  </div>

  <div>
    <%= form.submit "投稿する" %>
  </div>
<% end %>

リクエストが正常に完了すると、Turboはturbo:submit-endイベントを発火します。Stimulusがそのシグナルを検知し、グローバルな状態を汚すことなくフォームフィールドを綺麗にクリアします。

ステップ6: リアルタイムフィードの動作確認

Puma Webサーバーを起動します。

bin/dev
# または
bin/rails server

ブラウザでウィンドウを2つ開き、並べてhttp://localhost:3000にアクセスします。左側のウィンドウで新しいお知らせを投稿してみてください。わずか50〜100ミリ秒で右側のウィンドウのフィード最上部にカードが表示され、同時に左側のフォームが自動でクリアされます。どちらかのウィンドウで削除ボタンをクリックすると、両方のビューから即座に項目が消去されるのが確認できます。

これで、カスタムのWebSocketチャネルを書いたり、フロントエンドのビルドツールチェーンを管理したりすることなく、Rails 8の規約のみに基づいた本番運用可能なリアクティブWebアプリケーションが完成しました。

Share: