Turbo Morphingの仕組みと実践ガイド
はじめに
Turbo 8で導入されたMorphing機能について、実際の動作原理から実践的な使い方まで、包括的に解説します。この記事では、WebSocketを使った通信の仕組み、適切なユースケース、そして陥りやすい落とし穴について説明します。
Turbo Morphingとは
背景
従来のTurboでは、高いUI精度が必要な場合にTurbo FramesやTurbo Streamsを使う必要がありましたが、部分更新を使うと開発の生産性が下がるという問題がありました。画面の特定領域や要素の管理、インタラクションの影響範囲を考慮する必要があり、複雑性が増していました。
特にHEY Calendarの開発中、カレンダーのレンダリングが複雑で、多様なビューや要素の表示により部分更新が爆発的に増え、大きな負担になっていました。
DOM Morphingの概念
DOM Morphingは、既存のDOMツリーを新しいものに完全に置き換えるのではなく、望ましい状態に変異させる手法です。これにより以下のクライアント側の状態を保持できます:
- スクロール位置
- フォーカス状態
- 選択テキスト
- CSSトランジション状態
- 開いているドロップダウンメニュー
Morphingの仕組み
基本的な動作フロー
従来のTurbo Drive(bodyの置き換え):
旧DOM → 完全削除 → 新DOM挿入
結果: スクロール位置、フォーカス、CSS状態などが失われる
Morphing:
旧DOM → 新DOMと差分比較 → 変更部分だけ書き換え
結果: 変更されていない部分はそのまま = クライアント状態保持
重要な理解:ページ全体の再レンダリング
Morphingは「変更した要素のmodelを使用している部分だけ置き換わる」のではなく、ページ全体を再レンダリングします。ただし、DOM morphingによってクライアント状態が保持されるため、ユーザーには部分更新のように見えます。
実際の処理:
- サーバーがページ全体のHTMLを再生成
- ブラウザに新しいHTML全体が送られる
- idiomorphがDOMツリー全体を比較
- 実際に変更があった部分だけDOMを書き換え
- 変更のない部分は触らないため、状態が保持される
Action Cable有無による機能比較
Turbo Morphingは、Action Cable(WebSocket)の有無によって利用できる機能が異なります。
| 機能 | Action Cable不要 | Action Cable必要 |
|---|---|---|
| 自分の操作でページリフレッシュ | ✅ | - |
| morphingによる状態保持 | ✅ | ✅ |
| フォーム送信後のリダイレクト | ✅ | - |
| 手動Turbo Stream(単一ユーザー) | ✅ | - |
| broadcasts_refreshes | ❌ | ✅ |
| 他ユーザーへのリアルタイム配信 | ❌ | ✅ |
| WebSocketによる自動更新 | ❌ | ✅ |
重要なポイント:
- Action Cableなし:自分の操作(フォーム送信、リンククリックなど)によるページリフレッシュで、morphingによる状態保持の恩恵を受けられる
- Action Cableあり:他のユーザーの操作を自動的に自分の画面に反映できる(リアルタイムコラボレーション)
Action Cableなしでの使い方
重要: Turbo Morphingは、Action Cableなしでもmorphingとスクロール位置保持の恩恵を受けられます。
基本設定
HTMLの<head>内に以下のmetaタグを追加するだけで、自分の操作によるページリフレッシュ時にmorphingが有効になります。
<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">
Railsでの実装例:
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
<title>My App</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<!-- Turbo Morphing設定 -->
<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">
<%= stylesheet_link_tag "application" %>
<%= javascript_importmap_tags %>
</head>
<body>
<%= yield %>
</body>
</html>
フォーム送信後のリダイレクト
従来のRailsの書き方で、スクロール位置を保持したまま滑らかにページが更新されます。
コントローラー例:
class PostsController < ApplicationController
def create
@post = Post.create(post_params)
redirect_to posts_path # ← 通常のリダイレクトだけ
# Turbo Streamを書く必要なし!
end
def update
@post = Post.find(params[:id])
@post.update(post_params)
redirect_to @post # ← スクロール位置を保持したまま更新
end
def destroy
@post = Post.find(params[:id])
@post.destroy
redirect_to posts_path # ← リストのスクロール位置が保持される
end
end
フォーム例
<!-- app/views/posts/index.html.erb -->
<h1>投稿一覧</h1>
<!-- 新規投稿フォーム -->
<%= form_with model: Post.new, local: false do |f| %>
<%= f.text_field :title, class: "input" %>
<%= f.text_area :content, class: "textarea" %>
<%= f.submit "投稿", class: "btn" %>
<% end %>
<!-- 投稿一覧(長いリストを想定) -->
<div class="posts">
<% @posts.each do |post| %>
<div class="post">
<h2><%= post.title %></h2>
<p><%= post.content %></p>
<%= link_to "編集", edit_post_path(post) %>
<%= button_to "削除", post_path(post), method: :delete %>
</div>
<% end %>
</div>
動作:
- ユーザーがリストを下までスクロール
- 新規投稿フォームに入力して送信
- サーバーで投稿作成、
posts_pathにリダイレクト - → ページ全体が再レンダリングされるが、スクロール位置が保持される
- → フォームの入力内容はクリアされ、新しい投稿が表示される
- → ユーザーはスクロール位置を失わない
検索フィルター
<!-- app/views/products/index.html.erb -->
<h1>商品検索</h1>
<%= form_with url: products_path, method: :get, local: false do |f| %>
<%= f.text_field :q, value: params[:q], placeholder: "検索..." %>
<%= f.select :category, options_for_select(Category.all.pluck(:name, :id), params[:category]) %>
<%= f.submit "検索" %>
<% end %>
<div class="products">
<% @products.each do |product| %>
<div class="product">
<%= product.name %>
<%= product.price %>
</div>
<% end %>
</div>
コントローラー:
class ProductsController < ApplicationController
def index
@products = Product.all
@products = @products.where("name LIKE ?", "%#{params[:q]}%") if params[:q].present?
@products = @products.where(category_id: params[:category]) if params[:category].present?
end
end
動作:
- フィルター変更時もスクロール位置保持
- 検索ボックスのフォーカスが保持される
- ドロップダウンの選択状態が保持される
Morphingから除外する要素
モーダルやドロップダウンなど、ページリフレッシュ時に保持したい要素にはdata-turbo-permanent属性を付与します。
<div data-turbo-permanent id="user-menu">
<!-- この領域はmorphing時に保持される -->
<button>ユーザーメニュー</button>
<div class="dropdown">
<!-- ドロップダウンが開いていても閉じない -->
</div>
</div>
注意: data-turbo-permanentを使う要素には必ずid属性が必要です。
Action Cableなしで十分なケース
以下のような場合は、Action Cableなしでも十分です:
-
単一ユーザーのCRUD操作
- ブログの投稿作成・編集・削除
- 設定画面の更新
- プロフィール編集
-
検索・フィルタリング
- 商品検索
- データテーブルのソート・フィルター
- ページネーション
-
フォーム送信後のリダイレクト
- 問い合わせフォーム
- ログインフォーム
- アンケートフォーム
これらのケースでは、metaタグを設定するだけで、従来のRailsの書き方のまま、スクロール位置とフォーカスを保持した滑らかなページ更新が実現できます。
Action Cableありでの使い方(WebSocketによるリアルタイム通信)
Action Cableを使うことで、他のユーザーの操作を自動的に自分の画面に反映できます。Turbo Morphingの核心は、サーバー起因でクライアントのDOMが変わる点です。これはWebSocketによるリアルタイム通信で実現されています。
実際の通信フロー
ユーザーA(カンバンボード閲覧中)
↓
WebSocket接続維持
↓
ユーザーB「カードを追加」
↓
サーバー:Cardを保存
↓
サーバー:Boardが touch される
↓
サーバー:broadcasts_refreshes がトリガー
↓
サーバー:boards/show.html.erb を再レンダリング
↓
サーバー:WebSocketで全接続クライアントに送信
↓
ユーザーAのブラウザ:新しいHTMLを受信
↓
idiomorph:DOMを差分更新
↓
ユーザーAの画面:自動的に新しいカードが表示される
ネットワークで送信されるデータ
従来のTurbo Stream(部分更新):
<turbo-stream action="append" target="cards">
<template>
<div class="card">新しいカード</div>
</template>
</turbo-stream>
Morphing(ページリフレッシュ):
<turbo-stream action="refresh"></turbo-stream>
refreshアクションはページ全体のHTMLを直接送らず、ブラウザに「リフレッシュしろ」と指示するだけです。その後ブラウザが自分でページを再取得します。
broadcasts_refreshesによるブロードキャストの簡素化
従来は個別のDOM操作(prepend、remove、replaceなど)を各モデル変更ごとにブロードキャストする必要がありましたが、新しいrefreshストリームアクションを使うことで、単一のページリフレッシュで同じ効果を実現できます。
従来のブロードキャストシステム:
class Card < ApplicationRecord
after_create_commit -> { broadcast_prepend_to board }
after_destroy_commit -> { broadcast_remove_to board }
after_update_commit -> { broadcast_replace_to board }
end
class Column < ApplicationRecord
after_create_commit -> { broadcast_append_to board }
after_destroy_commit -> { broadcast_remove_to board }
after_update_commit -> { broadcast_replace_to board }
end
新しいブロードキャストシステム:
class Board < ApplicationRecord
broadcasts_refreshes
end
class Card < ApplicationRecord
belongs_to :board, touch: true
end
class Column < ApplicationRecord
belongs_to :board, touch: true
end
これにより100行以上のコードを数行に削減できます。
ビュー側:
<%= turbo_stream_from @board %>
Morphingが効果的なユースケース
Action Cableなしで実現できるケース
以下のユースケースは、metaタグの設定だけで実現できます(Action Cable不要)。
1. フォーム送信後のリダイレクト
class PostsController < ApplicationController
def create
@post = Post.create(post_params)
redirect_to posts_path # ← スクロール位置を保持したまま更新
end
end
投稿一覧ページで新規投稿を作成した後、リスト内のスクロール位置を保持したまま新しい投稿が表示されます。
2. 検索結果のライブフィルタリング
class SearchController < ApplicationController
def index
@results = Product.search(params[:q])
# フィルター変更時もスクロール位置保持
end
end
ユーザーがフィルターを変更しても、検索ボックスのフォーカスやページ内の開いているアコーディオンなどのクライアント状態が保持されます。
3. 単一ユーザーのCRUD操作全般
- ブログの記事作成・編集・削除
- プロフィール編集
- 設定画面の更新
- 管理画面でのデータ管理
これらは自分の操作による更新なので、Action Cableなしで十分です。
Action Cableが必要なケース(リアルタイムコラボレーション)
他のユーザーの操作を自動的に反映したい場合は、Action Cableが必要です。
1. リアルタイム更新が必要なダッシュボード
class Dashboard < ApplicationRecord
broadcasts_refreshes
end
プロジェクト管理ツールのダッシュボードで、タスクの進捗状況、チームメンバーのステータス、通知カウンターなどが複数の場所に表示されている場合に有効です。従来なら各要素ごとにTurbo Streamで更新処理を書く必要がありましたが、morphingなら単純にページ全体を再レンダリングするだけで、スクロール位置やフォーカスを保ったまま更新できます。
2. カレンダーやスケジュール画面(共同編集)
class Event < ApplicationRecord
belongs_to :calendar, touch: true
end
class Calendar < ApplicationRecord
broadcasts_refreshes
end
複数のユーザーがイベントを作成・更新・削除する場合、他のユーザーの変更が自動的に反映されます。複数のビュー(月表示、週表示、日表示)、ドラッグ&ドロップでの移動など、複雑なカレンダー操作に最適です。
3. カンバンボード(チームコラボレーション)
class Board < ApplicationRecord
broadcasts_refreshes
end
class Card < ApplicationRecord
belongs_to :column
belongs_to :board, touch: true
end
class Column < ApplicationRecord
belongs_to :board, touch: true
end
カードの移動、カラムの追加・削除、カードの編集など、多くの要素が相互に影響し合う画面に向いています。チームメンバーの操作がリアルタイムで反映されます。
4. コメントやチャットシステム
class Message < ApplicationRecord
belongs_to :conversation, touch: true
end
class Conversation < ApplicationRecord
broadcasts_refreshes
end
新しいメッセージが届いても、入力中のテキスト(フォーカス)が保持され、スクロール位置が維持され、開いているドロップダウンメニューが閉じません。
Morphingが向いていないケース
以下のような場合は従来のTurbo StreamやFrameの方が適切です:
- 非常に複雑なアニメーションが必要な場合
- ページの一部だけを更新したい明確な理由がある場合
- 異なるユーザーに異なる更新を送る必要がある場合(morphingはページ全体のリフレッシュなので)
broadcasts_refreshesの適切な使い方
❌ 全modelに書くべきでない理由
1. 不要なページリフレッシュが発生する
# 危険な例
class User < ApplicationRecord
broadcasts_refreshes # ← 危険
has_many :posts
has_many :comments
end
この場合の問題:
- ユーザーが自分のプロフィールを編集中
- 別タブで同じユーザーがコメントを投稿
- → プロフィール編集画面が勝手にリフレッシュされる
- → 入力中のフォームが再レンダリングされる(
data-turbo-permanentがない限り)
2. パフォーマンスの問題
# 危険な例
class Tag < ApplicationRecord
broadcasts_refreshes # ← 危険
has_and_belongs_to_many :posts
end
- Tagは多くのページで使われる可能性がある
- Tag更新のたびに、そのTagを表示している全ての接続ユーザーのページがリフレッシュ
- サーバー負荷とネットワーク帯域の無駄
3. 予期しない副作用
# 危険な例
class ActivityLog < ApplicationRecord
broadcasts_refreshes # ← 危険
end
- バックグラウンドジョブが頻繁にActivityLogを作成
- → ユーザーのページが意図せず頻繁にリフレッシュ
- → ユーザー体験の悪化
✅ 使うべきケース(判断基準)
1. 集約ルート(Aggregate Root)に使う
# 良い例:Boardが集約ルート
class Board < ApplicationRecord
broadcasts_refreshes
has_many :columns
has_many :cards, through: :columns
end
class Card < ApplicationRecord
belongs_to :column
belongs_to :board, touch: true # ← Boardを更新
end
class Column < ApplicationRecord
belongs_to :board, touch: true # ← Boardを更新
end
ポイント:
- 単一の「ページの主役」となるモデルだけに
broadcasts_refreshes - 子モデルは
touch: trueで親を更新 - ページのスコープが明確
2. 専用の画面がある場合
# 良い例:カレンダー専用画面
class Calendar < ApplicationRecord
broadcasts_refreshes
end
# 悪い例:あらゆる画面に出現
class User < ApplicationRecord
broadcasts_refreshes # ← NG
end
3. リアルタイム性が重要な画面
# 良い例:チャットの会話
class Conversation < ApplicationRecord
broadcasts_refreshes
end
# 悪い例:管理画面の設定
class SystemConfig < ApplicationRecord
broadcasts_refreshes # ← 不要、通常の更新で十分
end
判断フロー
このmodelにbroadcasts_refreshesを書くべきか?
1. このmodelは単一ページの「主役」か?
NO → 書かない
YES → 次へ
2. このmodelの更新は頻繁に起こるか?
YES(秒/分単位)→ 慎重に検討(debounce頼み)
NO(時/日単位)→ 次へ
3. このmodelを表示するページは複数あるか?
YES → 書かない(Turbo Streamで個別対応)
NO(専用画面がある)→ 次へ
4. リアルタイム更新が必要か?
NO → 書かない(通常の更新で十分)
YES → 書いてOK
推奨パターン
パターン1:明示的なスコープ
class Board < ApplicationRecord
# 特定のストリームにだけブロードキャスト
after_commit -> {
broadcast_refresh_to(self)
}
end
パターン2:条件付き
class Post < ApplicationRecord
belongs_to :board
after_commit :broadcast_board_refresh, if: :published?
private
def broadcast_board_refresh
board.broadcast_refresh
end
end
Turbo StreamとTurbo Frameとの関係
Turbo Stream
より高精度なUI更新が必要な場合に引き続き使用できますが、morphingにより使用頻度は減るはずです。従来は複雑なDOM操作を多数ブロードキャストする必要がありましたが、morphingによりシンプルなページリフレッシュで済むケースが増えます。
Turbo Frame
特定の画面領域の部分更新に使用されますが、morphingはこれらを置き換えるのではなく、フルページレスポンスによる「最も幸せな開発パス」を広げることを目指しています。
morphingはTurbo Driveの実装詳細として隠蔽され、新しい部分更新の手段としては導入されていません。開発者の幸福度を下げずに、より滑らかなページ更新を自動的に実現することが目標です。
セキュリティとアクセス制御
Action Cableを使う場合は、サーバー起因でDOMが変わるため、セキュリティとアクセス制御が非常に重要です。
class BoardsController < ApplicationController
def show
@board = current_user.accessible_boards.find(params[:id])
# ↑ 必ず権限チェック!
end
end
重要なポイント:
- 必ず認可チェックが必要
- WebSocket経由でリフレッシュ指示が来ても、再レンダリング時にはコントローラーが実行される
- →
current_userに基づいて適切なデータだけ表示される
Action Cableなしの場合は、通常のRailsのセキュリティ対策(認可、CSRF対策など)で十分です。
インフラ要件(Action Cableを使う場合のみ)
重要: Action Cableなしでmorphingを使う場合は、特別なインフラ要件はありません。通常のRailsアプリケーションと同じです。
Action Cableの設定
リアルタイム配信(broadcasts_refreshes)を使う場合、WebSocket接続を維持するため、Action Cableの設定が必要です。
# config/cable.yml
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
本番環境での考慮事項:
- RedisなどのバックエンドがWebSocket接続の管理に必要
- 多数のユーザーが同時接続する場合はスケーリング考慮
- WebSocketサーバーの負荷分散
まとめ
Turbo Morphingは、従来のSPAの複雑さなしに、ページ更新時の状態保持とリアルタイム更新を実現する革新的な機能です。
Morphingの2つの使い方
1. Action Cableなし(最もシンプル)
↓
metaタグを設定するだけ
↓
自分の操作でのページ更新時に状態保持
(フォーム送信、検索、CRUD操作など)
2. Action Cableあり(リアルタイムコラボレーション)
↓
broadcasts_refreshesを設定
↓
他ユーザーの操作が自動的に反映
(カンバン、チャット、共同編集など)
Morphingの本質
従来のSPA: クライアント側で状態管理、DOM操作
↓
複雑なJavaScript、サーバーとの同期が困難
Turbo Morphing: サーバーが唯一の真実の源(Single Source of Truth)
↓
サーバーがHTMLを生成→DOM morphingで状態保持
↓
シンプルなRailsの開発モデルを維持
導入の推奨ステップ
-
まずAction Cableなしで始める
- metaタグを設定(2行追加するだけ)
- フォーム送信後のリダイレクトなどで効果を体感
-
必要に応じてAction Cableを追加
- リアルタイムコラボレーションが必要になったら
- インフラ(Redis)の準備
-
broadcasts_refreshesの設定
ベストプラクティス
Action Cableなしの場合:
- metaタグを設定するだけ
- 特別なインフラ不要
- 従来のRailsの書き方でOK
Action Cableありの場合:
- 集約ルートにだけ
broadcasts_refreshesを書く - 専用画面があるmodelに限定
- リアルタイム性が重要な場合のみ使用
- セキュリティと認可を必ず実装
- インフラ(Redis、WebSocket)の準備を忘れずに
Turbo Morphingは、フルページレンダリングというシンプルな開発モデルを維持しながら、部分更新のような滑らかさを実現します。まずはAction Cableなしで始めて、必要に応じてリアルタイム機能を追加するという段階的な導入が推奨されます。