Ruby on Rails 7: HotwireとTurboによるリアクティブアプリケーション

Rails 7におけるHotwireとTurboの完全ガイド。Turbo Drive、Frames、Streamsを使用してJavaScriptを書かずにリアクティブアプリケーションを構築する方法。

Ruby on Rails 7のHotwireとTurboガイド

Rails 7はHotwireをデフォルトで統合することにより、Web開発に革命をもたらしました。このスタックにより、カスタムJavaScriptを一行も書かずに、高度にリアクティブなアプリケーションを構築できます。Turbo Drive、Turbo Frames、Turbo Streamsは、従来のSPAアプローチを「HTML over the wire」の哲学で置き換えます。

なぜHotwireなのか?

Hotwireはフロントエンドの複雑さを大幅に削減します。動的なインターフェースにReactやVueは不要です。サーバーがすぐに使えるHTMLを送信し、TurboがDOM更新を自動的に処理します。

Hotwireアーキテクチャの理解

Hotwireは3つの補完的なテクノロジーで構成されており、JavaScriptフレームワーク特有の複雑さなしにスムーズなユーザー体験を提供します。

Turbo Driveはリンクのクリックとフォーム送信をインターセプトすることでナビゲーションを高速化します。ページ全体を再読み込みする代わりに、<body>のコンテンツのみが置き換えられ、JavaScriptとCSSのコンテキストが保持されます。

Turbo Framesはページを独立したセクションに分解します。各フレームは個別に更新でき、ページの残りの部分に影響を与えずにターゲットを絞ったインタラクションを可能にします。

Turbo StreamsはWebSocket経由またはHTTPリクエストへのレスポンスとしてリアルタイム更新を実現します。宣言的なDOM操作のための8つのCRUDアクションが利用可能です。

ruby
# Gemfile
# Installing Turbo Rails (included by default in Rails 7+)
gem 'turbo-rails'

既存のプロジェクトでは、インストールに必要なコマンドは1つだけです。

bash
# installation.sh
# Installing Turbo in an existing Rails project
rails turbo:install

# Verify the JavaScript import is present
cat app/javascript/application.js
# Should contain: import "@hotwired/turbo-rails"

Turbo Driveの初期設定

Turbo DriveはRails 7でデフォルトで有効になっています。すべてのナビゲーションが追加設定なしで自動的に「turbo」になります。動作はdata属性でカスタマイズできます。

erb
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <meta name="turbo-cache-control" content="no-preview">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>
  <body>
    <!-- Progress bar during navigation -->
    <div class="turbo-progress-bar"></div>
    <%= yield %>
  </body>
</html>

必要に応じて、特定のリンクやフォームでTurbo Driveを無効にできます。

erb
<!-- app/views/pages/examples.html.erb -->
<!-- Link with Turbo enabled (default) -->
<%= link_to "Dashboard", dashboard_path %>

<!-- Disable Turbo for a specific link -->
<%= link_to "Download PDF", export_path, data: { turbo: false } %>

<!-- Disable Turbo for a form (file upload for example) -->
<%= form_with model: @document, data: { turbo: false } do |f| %>
  <%= f.file_field :attachment %>
  <%= f.submit "Upload" %>
<% end %>

<!-- Turbo with confirmation -->
<%= link_to "Delete", item_path(@item),
    data: { turbo_method: :delete, turbo_confirm: "Are you sure?" } %>
Turbo Driveのキャッシュ

Turbo Driveは訪問したページをキャッシュします。アセットのdata-turbo-track="reload"属性により、CSS/JSファイルが変更された場合に完全な再読み込みが強制されます。

ターゲット更新のためのTurbo Frames

Turbo Framesは独立して更新されるページゾーンを定義します。各フレームは一意の識別子を持ち、一致するフレームを含むレスポンスにのみ応答します。

erb
<!-- app/views/messages/index.html.erb -->
<h1>Messages</h1>

<!-- Frame for the message list -->
<turbo-frame id="messages_list">
  <div id="messages">
    <%= render @messages %>
  </div>

  <%= link_to "Load more", messages_path(page: @next_page) %>
</turbo-frame>

<!-- Frame for the creation form -->
<turbo-frame id="new_message">
  <%= render "form", message: Message.new %>
</turbo-frame>

更新が機能するためには、サーバーレスポンスに同じ識別子のフレームが含まれている必要があります。

erb
<!-- app/views/messages/_message.html.erb -->
<!-- Partial for an individual message -->
<turbo-frame id="<%= dom_id(message) %>">
  <div class="message">
    <p><%= message.content %></p>
    <span class="metadata">
      By <%= message.author.name %> - <%= time_ago_in_words(message.created_at) %>
    </span>

    <!-- These links stay within the frame -->
    <%= link_to "Edit", edit_message_path(message) %>
    <%= button_to "Delete", message_path(message), method: :delete %>
  </div>
</turbo-frame>

Turbo Framesによる遅延読み込み

フレームはsrc属性を使用してコンテンツを非同期的に読み込むことができます。読み込み中は初期コンテンツが表示されます。

erb
<!-- app/views/dashboard/show.html.erb -->
<h1>Dashboard</h1>

<!-- Statistics loaded asynchronously -->
<turbo-frame id="stats" src="<%= dashboard_stats_path %>">
  <div class="loading-placeholder">
    <p>Loading statistics...</p>
  </div>
</turbo-frame>

<!-- Notifications loaded when visible (lazy loading) -->
<turbo-frame id="notifications"
             src="<%= notifications_path %>"
             loading="lazy">
  <p>Loading notifications...</p>
</turbo-frame>

<!-- Recent activity with periodic refresh -->
<turbo-frame id="recent_activity"
             src="<%= activity_path %>"
             data-controller="auto-refresh"
             data-auto-refresh-interval-value="30000">
  <%= render "activity/placeholder" %>
</turbo-frame>

コントローラーは要求されたフレームのみを含むビューで応答します。

ruby
# app/controllers/dashboard_controller.rb
class DashboardController < ApplicationController
  def stats
    @user_count = User.count
    @message_count = Message.count
    @active_today = User.where("last_seen_at > ?", 24.hours.ago).count

    # Partial rendering for the frame
    render partial: "dashboard/stats"
  end
end
erb
<!-- app/views/dashboard/_stats.html.erb -->
<turbo-frame id="stats">
  <div class="stats-grid">
    <div class="stat-card">
      <h3><%= @user_count %></h3>
      <p>Users</p>
    </div>
    <div class="stat-card">
      <h3><%= @message_count %></h3>
      <p>Messages</p>
    </div>
    <div class="stat-card">
      <h3><%= @active_today %></h3>
      <p>Active today</p>
    </div>
  </div>
</turbo-frame>

Ruby on Railsの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

リアルタイム更新のためのTurbo Streams

Turbo StreamsはDOM操作のための8つのアクションを提供します:appendprependreplaceupdateremovebeforeaftermorph。これらのアクションはHTTPレスポンスまたはWebSocket経由でトリガーできます。

ruby
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  def create
    @message = current_user.messages.build(message_params)

    respond_to do |format|
      if @message.save
        # Turbo Stream response to add the message
        format.turbo_stream do
          render turbo_stream: [
            turbo_stream.prepend("messages", @message),
            turbo_stream.update("message_count", partial: "messages/count"),
            turbo_stream.replace("new_message", partial: "messages/form",
                                 locals: { message: Message.new })
          ]
        end
        format.html { redirect_to messages_path, notice: "Message created" }
      else
        format.turbo_stream do
          render turbo_stream: turbo_stream.replace(
            "new_message",
            partial: "messages/form",
            locals: { message: @message }
          )
        end
        format.html { render :new, status: :unprocessable_entity }
      end
    end
  end

  def destroy
    @message = Message.find(params[:id])
    @message.destroy

    respond_to do |format|
      # Removal with animation
      format.turbo_stream { render turbo_stream: turbo_stream.remove(@message) }
      format.html { redirect_to messages_path, notice: "Message deleted" }
    end
  end
end

専用Turbo Streamテンプレート

より複雑なレスポンスの場合、.turbo_stream.erbテンプレートがより柔軟性を提供します。

erb
<!-- app/views/messages/create.turbo_stream.erb -->
<!-- Prepend the new message to the list -->
<%= turbo_stream.prepend "messages" do %>
  <%= render @message %>
<% end %>

<!-- Update the counter -->
<%= turbo_stream.update "message_count" do %>
  <span><%= Message.count %> messages</span>
<% end %>

<!-- Reset the form -->
<%= turbo_stream.replace "new_message" do %>
  <%= render "form", message: Message.new %>
<% end %>

<!-- Display a flash notification -->
<%= turbo_stream.prepend "flash_messages" do %>
  <div class="flash flash-success" data-controller="auto-dismiss">
    Message sent successfully!
  </div>
<% end %>
レスポンス形式

Turbo Streamレスポンスはcontent-type text/vnd.turbo-stream.htmlを持つ必要があります。Railsはrespond_toturbo_streamフォーマットでこれを自動的に処理します。

Action Cableによるリアルタイムブロードキャスティング

Turbo StreamsはWebSocketブロードキャスティングで真価を発揮します。ユーザーはポーリングなしで即座に更新を受け取ります。

ruby
# app/models/message.rb
class Message < ApplicationRecord
  belongs_to :room
  belongs_to :author, class_name: "User"

  # Automatic broadcast after creation
  after_create_commit do
    broadcast_append_to(
      room,
      target: "messages",
      partial: "messages/message",
      locals: { message: self }
    )
  end

  # Broadcast after update
  after_update_commit do
    broadcast_replace_to(
      room,
      target: dom_id(self),
      partial: "messages/message",
      locals: { message: self }
    )
  end

  # Broadcast after deletion
  after_destroy_commit do
    broadcast_remove_to(room, target: dom_id(self))
  end
end

ビューはturbo_stream_fromヘルパーで対応するストリームにサブスクライブします。

erb
<!-- app/views/rooms/show.html.erb -->
<h1><%= @room.name %></h1>

<!-- Subscribe to the WebSocket stream -->
<%= turbo_stream_from @room %>

<!-- Container for messages -->
<div id="messages">
  <%= render @room.messages.order(created_at: :asc) %>
</div>

<!-- New message form -->
<%= form_with model: [@room, Message.new], id: "new_message" do |f| %>
  <%= f.text_area :content, placeholder: "Your message..." %>
  <%= f.submit "Send" %>
<% end %>

重い処理のための非同期ブロードキャスティング

リクエストをブロックしないために、ブロードキャスティングはバックグラウンドで実行できます。

ruby
# app/models/report.rb
class Report < ApplicationRecord
  belongs_to :user

  after_create_commit :generate_async

  private

  def generate_async
    GenerateReportJob.perform_later(self)
  end
end
ruby
# app/jobs/generate_report_job.rb
class GenerateReportJob < ApplicationJob
  queue_as :default

  def perform(report)
    # Simulating a long operation
    report.update!(status: "processing")

    # Notify user of start
    report.broadcast_replace_to(
      report.user, :reports,
      target: dom_id(report),
      partial: "reports/report"
    )

    # Generate the report
    result = ReportGenerator.new(report).generate
    report.update!(content: result, status: "completed")

    # Notify user of completion
    report.broadcast_replace_to(
      report.user, :reports,
      target: dom_id(report),
      partial: "reports/report"
    )
  end
end

Turboの高度なパターン

インラインフォーム編集

一般的なパターンとして、静的コンテンツをインライン編集フォームに置き換えるものがあります。

erb
<!-- app/views/tasks/_task.html.erb -->
<turbo-frame id="<%= dom_id(task) %>">
  <div class="task">
    <span class="task-content"><%= task.content %></span>
    <div class="task-actions">
      <%= link_to "Edit", edit_task_path(task) %>
      <%= button_to "Delete", task_path(task), method: :delete,
          data: { turbo_confirm: "Delete this task?" } %>
    </div>
  </div>
</turbo-frame>
erb
<!-- app/views/tasks/edit.html.erb -->
<turbo-frame id="<%= dom_id(@task) %>">
  <%= form_with model: @task do |f| %>
    <%= f.text_field :content, autofocus: true %>
    <div class="form-actions">
      <%= f.submit "Save" %>
      <%= link_to "Cancel", task_path(@task) %>
    </div>
  <% end %>
</turbo-frame>

親フレーム外へのナビゲーション

デフォルトでは、フレーム内のリンクはそのフレーム内に留まります。data-turbo-frame属性により、別のフレームやページ全体をターゲットにできます。

erb
<!-- app/views/search/_results.html.erb -->
<turbo-frame id="search_results">
  <ul>
    <% @results.each do |result| %>
      <li>
        <!-- This link navigates the entire page, not the frame -->
        <%= link_to result.title, result_path(result),
            data: { turbo_frame: "_top" } %>
      </li>
    <% end %>
  </ul>
</turbo-frame>
erb
<!-- app/views/layouts/_sidebar.html.erb -->
<aside>
  <!-- This frame loads content and updates main -->
  <turbo-frame id="sidebar_nav" target="main_content">
    <%= render "navigation" %>
  </turbo-frame>
</aside>

<main>
  <turbo-frame id="main_content">
    <%= yield %>
  </turbo-frame>
</main>
Turboのデバッグ

開発環境で、JavaScriptコンソールを開いてTurbo.setProgressBarDelay(0)と入力すると、プログレスバーがすぐに表示されます。Turbo.session.drive = falseでTurbo Driveを一時的に無効にできます。

エラーハンドリングとローディング状態

良いUXにはローディング状態とネットワークエラーの処理が必要です。

erb
<!-- app/views/shared/_form_with_loading.html.erb -->
<%= form_with model: @model,
    data: {
      controller: "form-loading",
      action: "turbo:submit-start->form-loading#disable turbo:submit-end->form-loading#enable"
    } do |f| %>
  <%= yield f %>

  <button type="submit" data-form-loading-target="submit">
    <span data-form-loading-target="text">Save</span>
    <span data-form-loading-target="spinner" class="hidden">
      <svg class="animate-spin h-5 w-5"><!-- spinner SVG --></svg>
    </span>
  </button>
<% end %>
app/javascript/controllers/form_loading_controller.jsjavascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["submit", "text", "spinner"]

  disable() {
    this.submitTarget.disabled = true
    this.textTarget.classList.add("hidden")
    this.spinnerTarget.classList.remove("hidden")
  }

  enable() {
    this.submitTarget.disabled = false
    this.textTarget.classList.remove("hidden")
    this.spinnerTarget.classList.add("hidden")
  }
}

サーバーエラーの処理

ruby
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  rescue_from ActiveRecord::RecordNotFound do |exception|
    respond_to do |format|
      format.turbo_stream do
        render turbo_stream: turbo_stream.replace(
          "main_content",
          partial: "shared/not_found"
        )
      end
      format.html { render "errors/not_found", status: :not_found }
    end
  end
end

パフォーマンス最適化

いくつかのテクニックにより、パフォーマンスの高いTurboアプリケーションを確保できます。

ruby
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  # Avoid N+1 queries with eager loading
  def index
    @messages = Message.includes(:author, :room)
                       .order(created_at: :desc)
                       .page(params[:page])
  end

  # Fragment caching for static elements
  def show
    @message = Message.find(params[:id])
    fresh_when @message
  end
end
erb
<!-- app/views/messages/_message.html.erb -->
<!-- Partial-level caching -->
<% cache message do %>
  <turbo-frame id="<%= dom_id(message) %>">
    <div class="message">
      <%= message.content %>
      <small>By <%= message.author.name %></small>
    </div>
  </turbo-frame>
<% end %>

まとめ

HotwireとTurboは、従来のJavaScriptフレームワークの複雑さを排除することでRails開発を変革します。Turbo Driveはナビゲーションを高速化し、Turbo Framesはターゲットを絞った更新を可能にし、Turbo Streamsは強力なリアルタイム機能を提供します。

Hotwire入門チェックリスト

  • Hotwireの組み込み統合のためにRails 7以上を使用する
  • 3つのコンポーネントを理解する:Drive、Frames、Streams
  • Turbo Framesの恩恵を受けるページゾーンを特定する
  • コントローラーでrespond_toformat.turbo_streamを使用する
  • リアルタイム機能のためにブロードキャスティングを実装する
  • シンプルなJavaScriptインタラクションのためにStimulusと組み合わせる

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

この「HTML over the wire」アプローチにより、Ruby on Railsを強力にするシンプルさと生産性を維持しながら、モダンでリアクティブなアプリケーションを構築できます。結果として、メンテナンスするコードが少なく、優れたパフォーマンス、そして最適な開発者体験が得られます。

タグ

#ruby on rails
#hotwire
#turbo
#turbo frames
#turbo streams

共有