0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【解説】Rails 8 + Hotwire で作るSPAライクな業務管理システム(2026年版)

Last updated at Posted at 2026-01-01

Rails 8 + Hotwire で実現するSPAライクな開発体験

はじめに

従来のRails開発では、ユーザーのアクションに対してページ全体をリロードする必要がありました。モダンなWebアプリケーションでは、React や Vue.js を導入してSPA(Single Page Application)を構築することが一般的でしたが、Rails 8 + Hotwire を使えば、JavaScript フレームワークを使わずにSPAライクな体験を実現できます。

本記事では、実際のサンプルアプリケーション(GitHub)を元に、Hotwire の各機能を詳しく解説します。
尚、チュートリアルは【チュートリアル】Rails 8 + Hotwireで業務管理システムを作ってみた(2026年版)をご参照ください

従来のRails MVCとHotwireの比較

従来のRails MVC:フルリロードの世界

従来のRails開発では、以下のような実装が一般的でした:

# app/controllers/employees_controller.rb(従来版)
class EmployeesController < ApplicationController
  def create
    @employee = Employee.new(employee_params)
    
    if @employee.save
      redirect_to employees_path, notice: '従業員が作成されました'
    else
      render :new  # フォーム再表示(ページ全体リロード)
    end
  end
end
<!-- app/views/employees/index.html.erb(従来版) -->
<%= link_to "新規登録", new_employee_path %>
<!-- クリック時にページ全体が切り替わる -->

<% @employees.each do |employee| %>
  <tr>
    <td><%= employee.name %></td>
    <td>
      <%= link_to "編集", edit_employee_path(employee) %>
      <!-- 編集ページに遷移(ページ全体リロード) -->
    </td>
  </tr>
<% end %>

問題点:

  • ページ遷移のたびに全体がリロードされる
  • フォーム送信後、成功・失敗に関わらずページが切り替わる
  • ユーザー体験が途切れる

Hotwire版:部分更新の世界

同じ機能をHotwireで実装すると:

# app/controllers/employees_controller.rb(Hotwire版)
class EmployeesController < ApplicationController
  def create
    @employee = Employee.new(employee_params)
    
    respond_to do |format|
      if @employee.save
        format.html { redirect_to employees_path, notice: '従業員が正常に作成されました。' }
        format.turbo_stream  # 部分更新のみ
      else
        format.html { render :new, status: :unprocessable_entity }
        format.turbo_stream { render :new, status: :unprocessable_entity }
      end
    end
  end
end
<!-- app/views/employees/index.html.erb(Hotwire版) -->
<%= link_to "新規登録", new_employee_path, 
    data: { turbo_frame: "employee_form" } %>
<!-- フォームが同一ページ内に表示される -->

<turbo-frame id="employee_form"></turbo-frame>

<turbo-frame id="employees_list">
  <% @employees.each do |employee| %>
    <tr id="employee_<%= employee.id %>">
      <td><%= employee.name %></td>
      <td>
        <%= link_to "表示", employee, 
            class: "text-indigo-600 hover:text-indigo-900 mr-3",
            data: { turbo_frame: "_top" } %>
        <%= link_to "編集", edit_employee_path(employee),
            class: "text-indigo-600 hover:text-indigo-900 mr-3",
            data: { turbo_frame: "employee_form" } %>
        <%= link_to "削除", employee, method: :delete,
            class: "text-red-600 hover:text-red-900",
            confirm: "本当に削除しますか?",
            data: { turbo_method: :delete } %>
      </td>
    </tr>
  <% end %>
</turbo-frame>

改善点:

  • ページ遷移なしでフォーム表示
  • 部分更新のみでレコード追加
  • ユーザー体験が途切れない

Hotwire各機能の実装解説

Turbo Drive:高速ページ遷移

Turbo Drive は、通常のリンク(link_toヘルパー)やフォーム送信を自動的にAjax化し、<body> 部分のみを差し替えます。

<!-- 通常のリンク(link_toヘルパー)(自動的にTurbo Drive化される) -->
<%= link_to "ダッシュボード", root_path %>
<%= link_to "従業員", employees_path %>
<%= link_to "商品", products_path %>
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <!-- head部分は維持される -->
    <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>
  <body>
    <!-- この部分のみが差し替えられる -->
    <%= yield %>
  </body>
</html>

重要なポイント:

詳細ページへの遷移など、ページ全体を切り替えたい場合は data: { turbo_frame: "_top" } を指定します:

<%= link_to "表示", employee, 
    data: { turbo_frame: "_top" } %>

メリット:

  • 設定不要で自動的に高速化
  • CSS/JSの再読み込みが不要
  • ブラウザの戻る/進むボタンが正常動作

デメリット:

  • JavaScript の状態がリセットされる場合がある
  • 一部のサードパーティライブラリで問題が発生する可能性

Turbo Frames:部分更新

Turbo Frames を使うと、ページの特定部分のみを更新できます。

<!-- app/views/employees/index.html.erb -->
<div class="sm:flex sm:items-center">
  <div class="sm:flex-auto">
    <h1>従業員管理</h1>
  </div>
  <div class="mt-4 sm:mt-0">
    <%= link_to "新規登録", new_employee_path, 
        data: { turbo_frame: "employee_form" } %>
  </div>
</div>

<!-- フォーム表示エリア -->
<turbo-frame id="employee_form" class="mt-6"></turbo-frame>

<!-- 一覧表示エリア -->
<turbo-frame id="employees_list">
  <table>
    <% @employees.each do |employee| %>
      <tr id="employee_<%= employee.id %>">
        <td><%= employee.name %></td>
        <td><%= employee.email %></td>
        <td>
          <%= link_to "編集", edit_employee_path(employee),
              data: { turbo_frame: "employee_form" } %>
        </td>
      </tr>
    <% end %>
  </table>
</turbo-frame>
<!-- app/views/employees/new.html.erb -->
<turbo-frame id="employee_form">
  <div class="bg-white shadow sm:rounded-lg">
    <div class="px-4 py-5 sm:p-6">
      <h3>新規従業員登録</h3>
      
      <%= form_with model: @employee, local: false do |form| %>
        <%= form.text_field :name %>
        <%= form.email_field :email %>
        <%= form.text_area :address %>
        
        <div class="flex justify-end space-x-3">
          <%= link_to "キャンセル", employees_path, 
              data: { turbo_frame: "_top" } %>
          <%= form.submit "登録" %>
        </div>
      <% end %>
    </div>
  </div>
</turbo-frame>

ポイント:

  • turbo-frame タグで囲んだ部分のみが更新される
  • data: { turbo_frame: "employee_form" } でターゲットを指定
  • data: { turbo_frame: "_top" } でページ全体遷移も可能

Turbo Streams:リアルタイム更新

Turbo Streams を使うと、複数のDOM要素を同時に更新できます。

# app/controllers/employees_controller.rb
def create
  @employee = Employee.new(employee_params)
  
  respond_to do |format|
    if @employee.save
      format.html { redirect_to employees_path, notice: '従業員が正常に作成されました。' }
      format.turbo_stream  # create.turbo_stream.erb を呼び出し
    else
      format.html { render :new, status: :unprocessable_entity }
      format.turbo_stream { render :new, status: :unprocessable_entity }
    end
  end
end

def destroy
  @employee.destroy
  respond_to do |format|
    format.html { redirect_to employees_url, notice: '従業員が削除されました。' }
    format.turbo_stream  # destroy.turbo_stream.erb を呼び出し
  end
end
<!-- app/views/employees/create.turbo_stream.erb -->
<%= turbo_stream.prepend "employees_list tbody" do %>
  <tr id="employee_<%= @employee.id %>">
    <td><%= @employee.name %></td>
    <td><%= @employee.email %></td>
    <td>
      <%= link_to "表示", @employee, 
          class: "text-indigo-600 hover:text-indigo-900 mr-3",
          data: { turbo_frame: "_top" } %>
      <%= link_to "編集", edit_employee_path(@employee),
          class: "text-indigo-600 hover:text-indigo-900 mr-3",
          data: { turbo_frame: "employee_form" } %>
      <%= link_to "削除", @employee, method: :delete,
          class: "text-red-600 hover:text-red-900",
          confirm: "本当に削除しますか?",
          data: { turbo_method: :delete } %>
    </td>
  </tr>
<% end %>

<!-- フォームをクリア -->
<%= turbo_stream.update "employee_form", "" %>
<!-- app/views/employees/destroy.turbo_stream.erb -->
<%= turbo_stream.remove "employee_#{@employee.id}" %>

利用可能なアクション:

  • append / prepend:要素の追加
  • replace / update:要素の置換・更新
  • remove:要素の削除

重要な注意点:

各アクション(create、update、destroy)に対応するTurbo Streamビューファイルが必要です。ファイルが不足していると ActionController::UnknownFormat エラーが発生します。

Stimulus:軽量JavaScript

Stimulus:(スティミュラス)
Stimulus を使って、必要最小限のJavaScriptを追加できます。

// app/javascript/controllers/price_calculator_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["costPrice", "sellingPrice", "margin"]

  connect() {
    this.calculateMargin()
  }

  calculateMargin() {
    const costPrice = parseFloat(this.costPriceTarget.value) || 0
    const sellingPrice = parseFloat(this.sellingPriceTarget.value) || 0
    
    if (costPrice > 0 && sellingPrice > 0) {
      const margin = ((sellingPrice - costPrice) / sellingPrice * 100).toFixed(1)
      this.marginTarget.textContent = `利益率: ${margin}%`
    } else {
      this.marginTarget.textContent = "利益率: -"
    }
  }
}
<!-- app/views/products/new.html.erb -->
<%= form_with model: @product, local: false, 
    data: { controller: "price-calculator" } do |form| %>
  
  <div>
    <%= form.label :cost_price, "原価" %>
    <%= form.number_field :cost_price,
        data: { 
          price_calculator_target: "costPrice",
          action: "input->price-calculator#calculateMargin"
        } %>
  </div>

  <div>
    <%= form.label :selling_price, "売価" %>
    <%= form.number_field :selling_price,
        data: { 
          price_calculator_target: "sellingPrice",
          action: "input->price-calculator#calculateMargin"
        } %>
  </div>

  <!-- 計算結果表示 -->
  <div data-price-calculator-target="margin">利益率: -</div>
<% end %>

Stimulusの特徴:

  • HTMLの data 属性でJavaScriptと連携
  • 小さな機能単位でコントローラーを作成
  • 既存のHTMLに後から機能追加が容易

システムテストとは?

システムテストは、実際のブラウザを使ってアプリケーション全体をテストする手法です。ユーザーが実際に行う操作(クリック、入力、フォーム送信など)をシミュレートできます。

テストの基本構造

# test/system/employees_test.rb
require "application_system_test_case"

class EmployeesTest < ApplicationSystemTestCase
  setup do
    @employee = employees(:one)  # fixtureデータを使用
  end

  test "visiting the index" do
    visit employees_url          # ページにアクセス
    assert_selector "h1", text: "従業員管理"  # 要素の存在を確認
  end
end

解説:

  • ApplicationSystemTestCase:システムテスト用の基底クラス
  • setup:各テスト実行前に呼ばれる初期化メソッド
  • visit:指定したURLにアクセス
  • assert_selector:指定したCSSセレクタの要素が存在することを確認

Turbo Frameのテスト

test "should create employee with turbo frame" do
  visit employees_url
  
  # 「新規登録」ボタンをクリック
  click_on "新規登録"
  
  # Turbo Frameでフォームが表示されることを確認
  within "#employee_form" do
    fill_in "名前", with: "テスト太郎"
    fill_in "メールアドレス", with: "test@example.com"
    fill_in "住所", with: "東京都渋谷区"
    
    click_on "登録"
  end
  
  # Turbo Streamで一覧に追加されることを確認
  assert_text "テスト太郎"
  assert_text "test@example.com"
end

解説:

  • click_on:指定したテキストのボタンやリンクをクリック
  • within:指定したCSSセレクタ内でのみ操作を実行
  • fill_in:指定したラベルの入力フィールドに値を入力
  • assert_text:ページ内に指定したテキストが存在することを確認

なぜこのテストが重要?

  1. Turbo Frameの動作確認:フォームが同一ページ内に表示される
  2. 非同期処理の検証:ページリロードなしでデータが追加される
  3. ユーザー体験の保証:実際の操作フローが正常に動作する

Turbo Streamのテスト

test "should destroy employee with turbo stream" do
  visit employees_url
  
  # 削除対象の行を特定
  within "#employee_#{@employee.id}" do
    # 確認ダイアログを受け入れて削除ボタンをクリック
    accept_confirm do
      click_on "削除"
    end
  end
  
  # Turbo Streamで要素が削除されることを確認
  assert_no_selector "#employee_#{@employee.id}"
end

解説:

  • accept_confirm:JavaScriptの確認ダイアログで「OK」をクリック
  • assert_no_selector:指定したCSSセレクタの要素が存在しないことを確認

テストのポイント:

  1. リアルタイム削除:ページリロードなしで要素が消える
  2. DOM操作の検証:Turbo Streamによる要素削除を確認
  3. 確認ダイアログ:ユーザーの誤操作防止機能もテスト

Stimulusのテスト

test "stimulus price calculator should work" do
  visit new_product_url
  
  # 原価と売価を入力
  fill_in "原価", with: "800"
  fill_in "売価", with: "1200"
  
  # Stimulusで利益率が自動計算されることを確認
  assert_text "利益率: 33.3%"
end

解説:

このテストでは、入力値の変更に応じてStimulusコントローラーが自動的に利益率を計算し、画面に表示されることを確認しています。

Stimulusテストの特徴:

  1. リアルタイム計算:入力と同時に結果が更新される
  2. JavaScript動作確認:ブラウザ上でのJS実行を検証
  3. ユーザビリティ:入力支援機能の動作を保証

複雑な操作のテスト

test "should update employee with turbo frame" do
  visit employees_url
  
  # 編集ボタンをクリック
  within "#employee_#{@employee.id}" do
    click_on "編集"
  end
  
  # Turbo Frameで編集フォームが表示される
  within "#employee_form" do
    fill_in "名前", with: "更新太郎"
    click_on "更新"
  end
  
  # Turbo Streamで一覧が更新される
  assert_text "更新太郎"
  
  # 編集フォームが非表示になる
  assert_no_selector "#employee_form form"
end

このテストが検証すること:

  1. 編集フォーム表示:Turbo Frameでの部分表示
  2. データ更新:フォーム送信による更新処理
  3. 画面更新:Turbo Streamでの一覧反映
  4. フォーム非表示:更新後のUI状態

テストデータの準備(Fixtures)

# test/fixtures/employees.yml
one:
  name: 北海道 太郎
  email: hokkaido.taro@example.com
  address: 北海道札幌市中央区北1条西1丁目1-1

two:
  name: 神奈川 次郎
  email: kanagawa.jiro@example.com
  address: 神奈川県横浜市西区みなとみらい1-1-1

Fixturesの役割:

  • 一貫したテストデータ:毎回同じ条件でテスト実行
  • 関連データの管理:複数テーブル間の関係を定義
  • テストの独立性:各テストが他に影響しない

テスト実行とデバッグ

# 全システムテストを実行
rails test:system

# 特定のテストファイルのみ実行
rails test:system test/system/employees_test.rb

# 特定のテストメソッドのみ実行
rails test:system test/system/employees_test.rb -n test_should_create_employee_with_turbo_frame

テスト失敗時のデバッグ:

  1. スクリーンショット:失敗時の画面状態を確認
  2. ログ確認log/test.log でサーバーサイドの動作を確認
  3. ブラウザ表示driven_by :selenium_chrome で実際のブラウザを表示

Hotwireテストのベストプラクティス

1. 適切なセレクタの使用

# ❌ 脆弱なセレクタ
assert_selector "div.mt-4"

# ✅ 意味のあるセレクタ
assert_selector "#employee_form"
assert_text "従業員が正常に作成されました"

2. 非同期処理の考慮

# Capybaraが自動的に要素の出現を待機
within "#employee_form" do
  fill_in "名前", with: "テスト太郎"
  click_on "登録"
end

# Turbo Streamの完了を待って検証
assert_text "テスト太郎"

3. テストの独立性

setup do
  # 各テストで必要なデータを準備
  @employee = employees(:one)
end

teardown do
  # 必要に応じてクリーンアップ
end

まとめ:なぜHotwireのテストが重要か

  1. ユーザー体験の保証:実際の操作フローが正常に動作することを確認
  2. 非同期処理の検証:Ajax通信やDOM操作が期待通りに動作することを確認
  3. リグレッション防止:機能追加時に既存機能が壊れていないことを確認
  4. 開発効率の向上:手動テストの時間を削減し、自動化による品質向上

Hotwireのテストは、従来のRailsテストよりも「ユーザーの実際の操作」に近い形でテストできるため、より実践的で価値の高いテストが書けます。

開発効率・保守性の観点

開発効率・保守性の観点

コード量の比較

従来のRails + jQuery

// 従来のjQuery実装
$(document).ready(function() {
  $('#new-employee-btn').click(function() {
    $.get('/employees/new', function(data) {
      $('#employee-form').html(data);
    });
  });
  
  $('#employee-form').on('submit', 'form', function(e) {
    e.preventDefault();
    $.post($(this).attr('action'), $(this).serialize())
      .done(function(data) {
        $('#employees-list').prepend(data);
        $('#employee-form').empty();
      })
      .fail(function(xhr) {
        $('#employee-form').html(xhr.responseText);
      });
  });
});

Hotwire版

<!-- HTMLのみで同等の機能を実現 -->
<%= link_to "新規登録", new_employee_path, 
    data: { turbo_frame: "employee_form" } %>

<turbo-frame id="employee_form"></turbo-frame>

よくある問題と解決方法

1. "Content missing" エラー

原因: Turbo Frameの範囲内で、対応するframe idが見つからない
解決方法: ページ全体遷移が必要な場合は data: { turbo_frame: "_top" } を指定

<!-- ❌ 詳細ページへのリンクでエラーが発生 -->
<%= link_to "表示", employee %>

<!-- ✅ 正しい実装 -->
<%= link_to "表示", employee, data: { turbo_frame: "_top" } %>

2. ActionController::UnknownFormat エラー

原因: Turbo Streamのビューファイル(.turbo_stream.erb)が不足
解決方法: 必要なアクションに対応するTurbo Streamビューを作成

<!-- app/views/employees/destroy.turbo_stream.erb -->
<%= turbo_stream.remove "employee_#{@employee.id}" %>

テストの書きやすさ

# test/system/employees_test.rb
class EmployeesTest < ApplicationSystemTestCase
  test "従業員を新規作成できる" do
    visit employees_path
    
    click_link "新規登録"
    # Turbo Frameでフォームが表示される
    
    fill_in "名前", with: "テスト太郎"
    fill_in "メールアドレス", with: "test@example.com"
    
    click_button "登録"
    # Turbo Streamで一覧に追加される
    
    assert_text "テスト太郎"
  end
end

メリット:

  • 通常のシステムテストと同じ書き方
  • JavaScript の非同期処理を意識する必要がない
  • Capybara が自動的に待機処理を行う

デバッグの容易さ

# ログでTurbo Streamsの動作を確認
def create
  @employee = Employee.new(employee_params)
  
  respond_to do |format|
    if @employee.save
      Rails.logger.info "Turbo Stream: 従業員作成成功"
      format.turbo_stream
    else
      Rails.logger.info "Turbo Stream: バリデーションエラー"
      format.turbo_stream { render :new, status: :unprocessable_entity }
    end
  end
end

デバッグツール:

  • ブラウザの開発者ツールでTurbo Streamの内容を確認可能
  • Rails のログでリクエスト/レスポンスを追跡
  • 通常のRailsデバッグ手法がそのまま使用可能

チーム開発での利点

学習コストの低さ

  • 既存のRails知識で開発可能
  • JavaScript フレームワーク特有の概念が不要

役割分担の明確さ

  • バックエンド:Rails(従来通り)
  • フロントエンド:HTML + 少しのStimulus
  • API設計が不要

保守性の高さ

  • Railsの規約に従った開発
  • ファイル構成が分かりやすい

パフォーマンス面での考慮点

# 従来:毎回全ページ取得
def index
  @employees = Employee.all  # 毎回全データ取得
  # レイアウト + ビュー全体をレンダリング
end

# Hotwire:必要な部分のみ更新
def create
  @employee = Employee.new(employee_params)
  if @employee.save
    # 新しいレコードの HTML のみ生成
    format.turbo_stream
  end
end

全画面ロードと比較するとパフォーマンス改善が見込める点

  • ネットワーク転送量の削減(部分更新のみ)
    • 変更点のみの読込なので軽量化が図れる
  • CSS/JavaScript の再読み込み不要
    • ページ遷移時にCSSやJSを再読み込み・再評価しないため、体感速度が向上
  • DOM の再構築範囲が限定的
    • クライアント側でJSONをHTMLに変換する処理を省き、サーバーが生成したHTMLを直接DOMに流し込むため、特にモバイル端末の読み込みに効果的

参考サイト: Hotwire Handbook - Turbo Drive

まとめ

Hotwire採用の判断基準

Hotwireが適している場合:

  • 従来のRailsアプリケーションをモダン化したい
  • チームにモダンなフロントエンドフレームワークの経験者が少ない
  • バックエンドロジックとフロントエンドロジックを分離したくない
  • 開発・保守コストを抑えたい
  • SEOが重要(サーバーサイドレンダリング)

以外の場合:

  • Rails標準: MVC (Model–View–Controller)
  • Inertia.js + Rails(inertia-rails)
  • APIモード+SPA分離(Rails API + React/Vue.js/Angular)

今後の展望

Rails 8 + Hotwire の組み合わせは、**「シンプルさと現代的なUX」**を両立する選択肢として挙げられる

開発効率の向上

JavaScript フレームワーク不要で開発速度アップ

保守性の確保

Rails の規約に従った分かりやすい構成

段階的導入

既存アプリケーションに部分的に導入可能

サンプルアプリケーション(GitHub)では、実際のビジネスアプリケーションでよく使われる機能を Hotwire で実装しています。ぜひcloneして、試してみてください


参考リンク:

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?