rails-hotwire-realtime について
rails-hotwire-realtimeは、Turbo、Stimulus、ActionCableを使うRails開発者向けの設計・レビューガイドです。Turbo Streamsと軽量なActionCable信号の使い分けに加え、リアルタイム更新、接続の安全性、テストを扱います。
主な機能
- 01Streams-onlyとCable併用構成の選択
- 02Turbo Streamsによるページ更新
- 03ユーザー・テナント単位のブロードキャスト
- 04再接続後の差分取得
- 05Stimulusコントローラーの後処理と連携
- 06リアルタイム機能のテストパターン
活用シーン
- 01CRUDアプリのリアルタイム更新方式の選定
- 02複数領域を一括更新するページの実装
- 03チャットのプレゼンスや入力通知の追加
- 04複数ユーザーのリアルタイム配信テスト
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ marckohlbrugge/ 37signals-skills --skill rails-hotwire-realtimeインストール後、Claude Code で/rails-hotwire-realtimeを入力して読み込まれたか確認します。
詳細
Rails Hotwire + Realtime
Turbo/Stimulus/ActionCable のアーキテクチャやレビューに使用します。Campfire(Cable を多用するチャット)と Fizzy(ストリームのみのカンバン)のパターンを紹介します。
適切なリアルタイムアーキテクチャを選ぶ
37signals で実証された 2 つのトポロジーがあります。
- ストリームのみ(Fizzy): カスタムの ActionCable チャンネルは一切使いません。
turbo_stream_from、broadcasts_refreshesと morph、ETag を使った遅延フレームを使用します。CRUD 的なアプリでは、こちらをデフォルトにしてください。 - Cable 拡張(Campfire): プレゼンス、入力中、未読通知など、HTML をサーバー側でレンダリングするのが無駄になる軽量な JSON シグナルに限って、カスタムチャンネルを使います。永続的な DOM の状態は引き続き Turbo Streams で処理します。
目安:DOM を変更するものには Turbo Streams を使い、一時的な小さなシグナルに限って ActionCable を直接使います。
Turbo のデフォルト設定
- 重いクライアント側の状態管理システムより、サーバーレンダリングによる部分更新を優先します。
- レイアウトでグローバルな morph リフレッシュを設定します:
turbo_refreshes_with method: :morph, scroll: :preserve。 - 対象を絞った更新には Turbo Streams を使います。1 つのアクションで複数の対象を更新するストリームテンプレートをレンダリングし、影響を受けるすべての領域(元の列+移動先+詳細ペイン)をアトミックに更新できます。
turbo_frame_tag ..., src:, loading: :lazyで負荷の高いセクションを遅延読み込みし、フレームのエンドポイントには独自のfresh_whenETag を設定します。頻繁に変更される断片は独立したフレームに切り出し、親のキャッシュが無効にならないようにします。- ナビゲーションや morph の後も維持する必要がある要素(フッターのトレイ、編集中のエディターなど)には
data-turbo-permanentを使います。 turbo:before-morph-attributeとpreventDefault()を使って、クライアントが所有する状態(localStorage による列の折りたたみ状態など)が morph によって上書きされないようにします。- リアルタイム処理の多いページは Turbo のページキャッシュから除外し(
turbo_exempts_page_from_cache)、代わりにフレーム単位の ETag を使用します。 - JavaScript フレームワークを使わずに楽観的 UI を実現するには、
$placeholder$トークンを含む<template>パーシャルをサーバーでレンダリングし、送信前にクライアントで複製して生成した ID を付与します。その後、ストリームのレスポンスで置き換えます。
ブロードキャストのパターン
- ブロードキャストのロジックはコントローラーではなく、モデルに置きます(
broadcast_createを含むMessage::Broadcastsconcern など)。 - すべてのストリーム名をテナント/ユーザーでスコープします:
[board.account, :all_boards]、[user, :notifications]— ストリーム名は分離境界です。 - 必要に応じて二重のストリームを使います:直接のサブスクライバーには
broadcasts_refreshesを、アカウント全体のビューにはbroadcasts_refreshes_to ->(r) { [r.account, :aggregate_view] }を使用します。 - 意味のある変更があった場合にのみ、ノイズの多い副次的なブロードキャストを実行します。
before_updateでプレビューに関係するフィールドが変更されたときにフラグを設定し、if: :preview_changed?でブロードキャストします。 - レコードに付随的な変更を加えるバックグラウンドジョブでは、ブロードキャストを抑制します(例:ActiveStorage の解析処理の前後で
Model.suppressing_turbo_broadcastsを使用)。 - 効率的にファンアウトします。
render_to_stringは一度だけ実行し、各受信者に対してbroadcast_replace_to user, ..., html:を呼び出します。 - 同期ブロードキャストによってリクエストのレイテンシが悪化する場合は、非同期の
broadcast_*_laterを使います。 - ブロードキャストでレンダリングされたパーシャルにはリクエストコンテキストがありません。URL が解決されるよう、添付ファイル/URL ヘルパーをラップします(
broadcast_image_tagパターン)。
再接続と追いつき処理
- 再読み込みではなく、追いつき処理を行います:再接続時やタブに戻ったときに、turbo-stream の差分(
?since=<epoch_ms>)を取得します。サーバーは新しいレコードを追加し、更新されたレコードを置き換えます。 - 空の
HeartbeatChannelを使うと、追いつき処理やデバウンスされたオフライン UI を起動するための、信頼性の高いconnected/disconnectedコールバックを利用できます。 - Stimulus の
connect()は turbo-preview のチェックで保護し、戻る/進むキャッシュのプレビューでソケットを開いたり、権限をリクエストしたりしないようにします。
Stimulus のデフォルト
- コントローラーは単一の目的に絞り、1 つの要素に複数の小さなコントローラーを組み合わせます。
- 場当たり的なセレクターや属性の解析より、ターゲット/値を優先します。
disconnectではタイマー/リスナー/サブスクリプションを必ずクリーンアップします。Cable のサブスクリプションは、アニメーションフレームを 1 回遅らせて購読解除し、その間に要素が再接続された場合(Morph による頻繁な変更)は処理をスキップします。- コントローラー間の密結合を避け、イベントのディスパッチ(またはアウトレット)を使用します。
- 複雑な UI では、Ruby ヘルパー(
message_area_tag)でdata-controller/data-action/ターゲットの配線を組み合わせ、ビューは宣言的に保ち、契約を 1 か所にまとめます。 - JSON をインラインに記述する代わりに、
<meta>タグからページのコンテキストを読み込み、小さなwindow.Currentオブジェクトに格納します。 - ブロードキャストで後から追加された要素にも動作が適用されますか? 動的に挿入される子要素(例:
targetConnectedコールバック)に対応できるよう、コントローラーを設計します。
スクロールとインタラクションの契約
- スクロール位置は宣言的に維持します。ブロードキャストにカスタム属性(
maintain_scroll: true)を渡し、turbo:before-stream-renderリスナーで一度だけ処理します。 - ストリームと楽観的な挿入が競合する場合は、Promise キューを使ってスクロールの変更を順番に処理します。
- バックグラウンドのブロードキャストがフォーム自身のストリーム応答と競合しないよう、
turbo:submit-startでストリームターゲットのidを削除します。 - 無限フィードでは、turbo-stream ページネーションに IntersectionObserver のセンチネルを使います(トリガーを削除し、バッチを追加して、新しいセンチネルを追加します)。DOM のサイズに上限を設け、ユーザーが最新ページにいる場合にのみ自動スクロールします。
ActionCable/接続の安全性
Connection#connectで HTTP と同じ ID 解決を使って認証し、所有権を通じてチャンネルのサブスクリプションをスコープします(current_user.rooms.find_by(id: params[:room_id]))。- 無効化または禁止されたユーザーをリモートで切断します:
ActionCable.server.remote_connections.where(current_user: user).disconnect - プレゼンスでは、TTL を使って接続数を参照カウントします(
connectionsカウンターとconnected_at、有効期間 60 秒のスコープ)。これにより複数タブや再接続に対応できます。ちらつきを防ぐため、表示状態の変更をデバウンスします(約 5 秒)。 - パスベースのテナント分離では、カスタムの meta タグヘルパーを使い、
request.script_nameから Cable URL を出力します。
キャッシュとリアルタイム処理
- キャッシュキーは出力に影響する要素(レコード、ユーザー、タイムゾーン、フィルター状態)と一致させます。
touch: trueの連鎖を使い、子の編集で親を無効化します。 - キャッシュされたフラグメントはクライアント側でパーソナライズします。
data-creator-idをレンダリングし、Stimulus コントローラーでCurrent.userと比較して表示を切り替えます。ユーザーごとの切り替えのためにフラグメントキャッシュを無効にしないでください。 - トークンの鮮度が重要なフォームページでは、HTTP キャッシュを避けます。
Web Push(該当する場合)
- 接続していないユーザーにのみプッシュを送信し、操作を行ったユーザーは除外します。ユーザーごとの関与レベル(すべて、またはメンションのみ)を尊重します。
- 永続的な HTTP 接続を使うスレッドプール経由で配信し、スレッドに投稿する前にすべての AR データを解決します。期限切れのサブスクリプションは非同期で無効化します。
- ログアウト時に、クライアント側でプッシュサブスクリプションをクリーンアップします。
テスト
- モデルのブロードキャストには
assert_turbo_stream_broadcasts([user, :notifications], count: 1) { ... }を使い、ブロードキャストがないことを確認するにはassert_no_turbo_stream_broadcastsを使います。 - コントローラーのレスポンスには
as: :turbo_streamとassert_turbo_stream action: :replace, target: dom_id(...)を使います。 - ブロードキャストの DOM レベルのアサーションには、
ActionCable.server.pubsub.broadcastsを読み取る小さなヘルパーとassert_selectで十分です。 - システムテストでは、複数ユーザーのリアルタイム動作に
using_session("OtherUser")を使い、アサーションの前にturbo-cable-stream-source[connected]を待ちます。
注意すべき問題
- ストリーム ID/ターゲットが重複し、更新が不安定になる。
- テナント/ユーザー単位でスコープされていないブロードキャストチャンネルやストリーム名。
- 通常の ActionCable チャンネル内での HTML レンダリング(Turbo Streams を使用してください)。
- Stimulus コントローラーがナビゲーション後もタイマー/リスナーを解放せずに残す。
- ストリーミングで済む小さなインタラクションのために、ページ全体を置き換える。
turbo_stream_fromですでに実現できる機能に対して、独自の ActionCable チャンネルを作成する。- Morph による更新で入力途中のユーザー入力が失われる(
data-turbo-permanentが不足している)。
コメント
Markdown 対応