Rails 8 + Hotwire + TailwindCSS で作るモダンSPA風アプリケーション
概要
Rails 8の新機能を活用し、Hotwire(Turbo + Stimulus)とTailwindCSSを使用してSPA風の体験を提供するWebアプリケーションを構築します。
尚、詳細の解説は【解説】Rails 8 + Hotwire で作るSPAライクな業務管理システム(2026年版)をご参照ください
機能
マスタ管理機能
- 従業員管理: ID、ユーザー名、メールアドレス、住所
- 商品管理: ID、商品コード、商品名、原価、売価(利益率自動計算機能付き)
- 取引先管理: ID、得意先コード、得意先名
トランザクション機能
- 売上管理: 年月日毎の売上入力・管理
- 仕入管理: 年月日毎の仕入入力・管理
ダッシュボード機能
- 各マスタ情報のサマリー表示
- 各マスター登録ページへのリンク
- 売上・仕入のサマリー表示
前提条件
- WSL2(Ubuntu 20.04)
- Ruby 3.3以上
- Rails 8.0以上
- Node.js 18以上
実際のコード
プロジェクトセットアップ
1. 新しいRailsアプリケーションの作成
rails new hotwire_spa --css=tailwind
cd hotwire_spa
2. 必要なGemの追加
# Gemfile
gem "turbo-rails"
gem "stimulus-rails"
gem "tailwindcss-rails"
全文はこちら を参照ください
bundle install
3. データベースのセットアップ
rails db:create
rails db:migrate
サンプルコードの注意点
説明用に一部のみ記載しています
また、コードのブラッシュアップが足りない点が有り得ます
全文のコードはGitHubのコードを参照してください
モデルの作成
1. 基本モデルの生成
# 従業員モデル
rails generate model Employee name:string email:string position:string salary:decimal
# 商品モデル
rails generate model Product name:string price:decimal description:text stock:integer
# 顧客モデル
rails generate model Customer name:string email:string phone:string address:text
# 売上モデル
rails generate model Sale product:references customer:references quantity:integer total_amount:decimal sale_date:date
# 仕入モデル
rails generate model Purchase product:references quantity:integer unit_cost:decimal total_cost:decimal purchase_date:date
2. マイグレーションの実行
rails db:migrate
3. モデルの関連付け設定
# app/models/product.rb
class Product < ApplicationRecord
has_many :sales, dependent: :destroy
has_many :purchases, dependent: :destroy
validates :name, presence: true
validates :price, presence: true, numericality: { greater_than: 0 }
validates :stock, presence: true, numericality: { greater_than_or_equal_to: 0 }
end
# app/models/customer.rb
class Customer < ApplicationRecord
has_many :sales, dependent: :destroy
validates :name, presence: true
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
end
# app/models/employee.rb
class Employee < ApplicationRecord
validates :name, presence: true
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :salary, presence: true, numericality: { greater_than: 0 }
end
# app/models/sale.rb
class Sale < ApplicationRecord
belongs_to :product
belongs_to :customer
validates :quantity, presence: true, numericality: { greater_than: 0 }
validates :total_amount, presence: true, numericality: { greater_than: 0 }
validates :sale_date, presence: true
end
# app/models/purchase.rb
class Purchase < ApplicationRecord
belongs_to :product
validates :quantity, presence: true, numericality: { greater_than: 0 }
validates :unit_cost, presence: true, numericality: { greater_than: 0 }
validates :total_cost, presence: true, numericality: { greater_than: 0 }
validates :purchase_date, presence: true
end
コントローラーの作成
1. ダッシュボードコントローラー
rails generate controller Dashboard index
# app/controllers/dashboard_controller.rb
class DashboardController < ApplicationController
def index
@total_sales = Sale.sum(:total_amount)
@total_purchases = Purchase.sum(:total_cost)
@profit = @total_sales - @total_purchases
@recent_sales = Sale.includes(:product, :customer).order(created_at: :desc).limit(5)
end
end
2. リソースコントローラーの生成
rails generate controller Employees index show new create edit update destroy
rails generate controller Products index show new create edit update destroy
rails generate controller Customers index show new create edit update destroy
rails generate controller Sales index show new create edit update destroy
rails generate controller Purchases index show new create edit update destroy
3. 各コントローラーの実装例(Employeesコントローラー)
# app/controllers/employees_controller.rb
class EmployeesController < ApplicationController
before_action :set_employee, only: [:show, :edit, :update, :destroy]
def index
@employees = Employee.all
@employee = Employee.new
end
def show
end
def new
@employee = Employee.new
end
def create
@employee = Employee.new(employee_params)
respond_to do |format|
if @employee.save
format.turbo_stream
format.html { redirect_to employees_path, notice: '従業員が正常に作成されました。' }
else
format.turbo_stream { render turbo_stream: turbo_stream.replace("employee_form", partial: "form", locals: { employee: @employee }) }
format.html { render :new, status: :unprocessable_entity }
end
end
end
def edit
end
def update
respond_to do |format|
if @employee.update(employee_params)
format.turbo_stream
format.html { redirect_to @employee, notice: '従業員が正常に更新されました。' }
else
format.turbo_stream { render turbo_stream: turbo_stream.replace("employee_form", partial: "form", locals: { employee: @employee }) }
format.html { render :edit, status: :unprocessable_entity }
end
end
end
def destroy
@employee.destroy
respond_to do |format|
format.turbo_stream
format.html { redirect_to employees_path, notice: '従業員が正常に削除されました。' }
end
end
private
def set_employee
@employee = Employee.find(params[:id])
end
def employee_params
params.require(:employee).permit(:name, :email, :position, :salary)
end
end
ビューの作成
1. レイアウトファイル
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
<title>Hotwire SPA</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body class="bg-gray-50">
<nav class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center space-x-8">
<%= link_to "ダッシュボード", root_path, class: "text-gray-900 hover:text-blue-600 px-3 py-2 text-sm font-medium" %>
<%= link_to "従業員", employees_path, class: "text-gray-900 hover:text-blue-600 px-3 py-2 text-sm font-medium" %>
<%= link_to "商品", products_path, class: "text-gray-900 hover:text-blue-600 px-3 py-2 text-sm font-medium" %>
<%= link_to "顧客", customers_path, class: "text-gray-900 hover:text-blue-600 px-3 py-2 text-sm font-medium" %>
<%= link_to "売上", sales_path, class: "text-gray-900 hover:text-blue-600 px-3 py-2 text-sm font-medium" %>
<%= link_to "仕入", purchases_path, class: "text-gray-900 hover:text-blue-600 px-3 py-2 text-sm font-medium" %>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<%= yield %>
</main>
</body>
</html>
2. ダッシュボードビュー
<!-- app/views/dashboard/index.html.erb -->
<div class="space-y-6">
<h1 class="text-3xl font-bold text-gray-900">ダッシュボード</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">
<span class="text-white font-bold">¥</span>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">総売上</dt>
<dd class="text-lg font-medium text-gray-900">¥<%= number_with_delimiter(@total_sales || 0) %></dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-red-500 rounded-full flex items-center justify-center">
<span class="text-white font-bold">¥</span>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">総仕入</dt>
<dd class="text-lg font-medium text-gray-900">¥<%= number_with_delimiter(@total_purchases || 0) %></dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center">
<span class="text-white font-bold">¥</span>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">利益</dt>
<dd class="text-lg font-medium text-gray-900">¥<%= number_with_delimiter(@profit || 0) %></dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">最近の売上</h3>
</div>
<ul class="divide-y divide-gray-200">
<% @recent_sales.each do |sale| %>
<li class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-10 h-10 bg-gray-300 rounded-full flex items-center justify-center">
<span class="text-sm font-medium text-gray-700"><%= sale.product.name.first %></span>
</div>
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900"><%= sale.product.name %></div>
<div class="text-sm text-gray-500"><%= sale.customer.name %></div>
</div>
</div>
<div class="text-sm text-gray-900">¥<%= number_with_delimiter(sale.total_amount) %></div>
</div>
</li>
<% end %>
</ul>
</div>
</div>
3. 従業員一覧ビュー
<!-- app/views/employees/index.html.erb -->
<div class="space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold text-gray-900">従業員管理</h1>
<%= link_to "新規追加", new_employee_path,
class: "bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded",
data: { turbo_frame: "employee_form" } %>
</div>
<%= turbo_frame_tag "employee_form" %>
<div class="bg-white shadow overflow-hidden sm:rounded-md" id="employees_list">
<%= turbo_frame_tag "employees" do %>
<ul class="divide-y divide-gray-200">
<% @employees.each do |employee| %>
<%= turbo_frame_tag "employee_#{employee.id}" do %>
<li class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-10 h-10 bg-gray-300 rounded-full flex items-center justify-center">
<span class="text-sm font-medium text-gray-700"><%= employee.name.first %></span>
</div>
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900"><%= employee.name %></div>
<div class="text-sm text-gray-500"><%= employee.position %></div>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-900">¥<%= number_with_delimiter(employee.salary) %></span>
<%= link_to "編集", edit_employee_path(employee),
class: "text-blue-600 hover:text-blue-900 text-sm",
data: { turbo_frame: "employee_form" } %>
<%= link_to "削除", employee_path(employee),
method: :delete,
class: "text-red-600 hover:text-red-900 text-sm",
confirm: "本当に削除しますか?" %>
</div>
</div>
</li>
<% end %>
<% end %>
</ul>
<% end %>
</div>
</div>
4. フォーム部分テンプレート
<!-- app/views/employees/_form.html.erb -->
<%= turbo_frame_tag "employee_form" do %>
<div class="bg-white shadow sm:rounded-lg mb-6">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
<%= employee.persisted? ? "従業員編集" : "新規従業員追加" %>
</h3>
<%= form_with model: employee, local: false, class: "space-y-4" do |form| %>
<% if employee.errors.any? %>
<div class="bg-red-50 border border-red-200 rounded-md p-4">
<ul class="text-sm text-red-600">
<% employee.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= form.label :name, "名前", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :name, class: "mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500" %>
</div>
<div>
<%= form.label :email, "メールアドレス", class: "block text-sm font-medium text-gray-700" %>
<%= form.email_field :email, class: "mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500" %>
</div>
<div>
<%= form.label :position, "役職", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :position, class: "mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500" %>
</div>
<div>
<%= form.label :salary, "給与", class: "block text-sm font-medium text-gray-700" %>
<%= form.number_field :salary, class: "mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500" %>
</div>
<div class="flex justify-end space-x-3">
<%= link_to "キャンセル", employees_path,
class: "bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded" %>
<%= form.submit employee.persisted? ? "更新" : "作成",
class: "bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" %>
</div>
<% end %>
</div>
</div>
<% end %>
Turbo Streamレスポンスの作成
1. 作成時のレスポンス
<!-- app/views/employees/create.turbo_stream.erb -->
<%= turbo_stream.prepend "employees", partial: "employee", locals: { employee: @employee } %>
<%= turbo_stream.replace "employee_form", "" %>
2. 更新時のレスポンス
<!-- app/views/employees/update.turbo_stream.erb -->
<%= turbo_stream.replace "employee_#{@employee.id}", partial: "employee", locals: { employee: @employee } %>
<%= turbo_stream.replace "employee_form", "" %>
3. 削除時のレスポンス
<!-- app/views/employees/destroy.turbo_stream.erb -->
<%= turbo_stream.remove "employee_#{@employee.id}" %>
Stimulusコントローラーの作成
1. 価格計算コントローラー
// app/javascript/controllers/price_calculator_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["quantity", "unitPrice", "total"]
calculate() {
const quantity = parseFloat(this.quantityTarget.value) || 0
const unitPrice = parseFloat(this.unitPriceTarget.value) || 0
const total = quantity * unitPrice
this.totalTarget.value = total.toFixed(2)
}
}
ルーティングの設定
# config/routes.rb
Rails.application.routes.draw do
root 'dashboard#index'
resources :employees
resources :products
resources :customers
resources :sales
resources :purchases
end
シードデータの作成
# db/seeds.rb
# 従業員データ
Employee.create!([
{ name: "田中太郎", email: "tanaka@example.com", position: "マネージャー", salary: 500000 },
{ name: "佐藤花子", email: "sato@example.com", position: "営業", salary: 400000 },
{ name: "鈴木一郎", email: "suzuki@example.com", position: "エンジニア", salary: 450000 }
])
# 商品データ
Product.create!([
{ name: "ノートPC", price: 80000, description: "高性能ノートパソコン", stock: 10 },
{ name: "マウス", price: 2000, description: "ワイヤレスマウス", stock: 50 },
{ name: "キーボード", price: 5000, description: "メカニカルキーボード", stock: 30 }
])
# 顧客データ
Customer.create!([
{ name: "山田商事", email: "yamada@company.com", phone: "03-1234-5678", address: "東京都渋谷区" },
{ name: "田中企画", email: "tanaka@planning.com", phone: "03-8765-4321", address: "東京都新宿区" }
])
説明用に一部のみ記載しています
全文のコードはGitHubのコードを参照してください
rails db:seed
起動とテスト
# 開発サーバーの起動
rails server
# ブラウザで http://localhost:3000 にアクセス
主要な特徴
- Turbo Drive: ページ遷移の高速化
- Turbo Frames: 部分的なページ更新
- Turbo Streams: リアルタイムな画面更新
- Stimulus: 軽量なJavaScriptフレームワーク
- TailwindCSS: ユーティリティファーストのCSS
まとめ
このチュートリアルでは、Hotwireを活用して、SPAライクの開発体験が出来るWebアプリケーションを構築しました
従来のRailsアプリケーションと比較して、ページの再読み込みなしでスムーズな操作が可能になり、モダンなユーザー体験を実現できます
