はじめに
この記事は、Railsアプリで使われるデザインパターンを解説するシリーズの第7回です。
第1回から読むことをお勧めします。
| 記事 | 何を解決するか |
|---|---|
| 記事1: Value Object | ロジックの分散 |
| 記事2: Validator Object + Form Object | インラインバリデーション |
| 記事3: Query Object + Service Object | インラインクエリ・集計ロジック |
| 記事4: Adapter + ActiveJob + Pub/Sub | 通知のハードコード・同期処理 |
| 記事5: Workflow + Command | ファットコントローラ・トランザクション管理 |
| 記事6: Policy Object + Concern + Callback | 認可・共通スコープ・監査ログ |
| 記事7: Presenter + ViewComponent | モデルへの表示ロジック混入 |
Presenter + ViewComponent パターン
対応PR:
概要
Presenter(Decorator とも呼ばれる)は、ビュー用のフォーマットメソッドをモデルから
切り出す専用クラスです。モデルがビューの都合を知らなくてよくなります。
ViewComponent は、UI の断片を Ruby クラス + ERB テンプレートとしてカプセル化する
パターン(および gem)です。再利用可能なコンポーネントをユニットテストできます。
問題:アンチパターン
main ブランチでは、表示用メソッドがモデルに直書きされています。
# app/models/weekly_report.rb (feature/12-callback 時点)
class WeeklyReport < ApplicationRecord
# ...
def formatted_weight
avg_weight_g ? "#{avg_weight_g / 1000.0} kg" : "データなし"
end
def formatted_fat
avg_body_fat_bp ? "#{avg_body_fat_bp / 100.0}%" : "データなし"
end
def formatted_calories
total_calories_kcal ? "#{total_calories_kcal} kcal" : "データなし"
end
def notification_label
notified_at ? "送信済み #{notified_at.strftime('%m/%d %H:%M')}" : "未送信"
end
def formatted_period
"#{period_start} 〜 #{period_end}"
end
end
問題点
-
関心の混在: モデルは「データの保持・永続化」が責務。
「表示フォーマット」はビューの関心事なのにモデルに混入している - モデルの肥大化: ビジネスロジックに加えて表示メソッドが増え続ける
- 再利用の困難さ: 表示ロジックをテストするためにモデル全体が必要
- ビューテンプレートの断片化: 複雑な UI ロジックがビューに散らばりやすい
解決策1:Presenter の導入
# app/presenters/weekly_report_presenter.rb
class WeeklyReportPresenter
delegate :id, :user, :period_start, :period_end, :notified_at, to: :@report
def initialize(report)
@report = report
end
def formatted_period
"#{period_start} 〜 #{period_end}"
end
def formatted_weight
@report.avg_weight_g ? "#{@report.avg_weight_g / 1000.0} kg" : "データなし"
end
def formatted_fat
@report.avg_body_fat_bp ? "#{@report.avg_body_fat_bp / 100.0}%" : "データなし"
end
def formatted_calories
@report.total_calories_kcal ? "#{@report.total_calories_kcal} kcal" : "データなし"
end
def notified?
@report.notified_at.present?
end
def notification_label
notified? ? "送信済み #{notified_at.strftime('%m/%d %H:%M')}" : "未送信"
end
end
delegate により、period_start などのモデル属性に直接アクセスできます。
コントローラでは show アクションで Presenter を生成します。
# app/controllers/weekly_reports_controller.rb (feature/14-presenter)
def show
# Presenter でビューロジックを分離
@presenter = WeeklyReportPresenter.new(@report)
end
ビューは @report ではなく @presenter を使います。
<%# app/views/weekly_reports/show.html.erb (feature/14-presenter) %>
<h1>週次レポート</h1>
<div class="weekly-summary-card">
<h3><%= @presenter.formatted_period %></h3>
<dl>
<dt>平均体重</dt><dd><%= @presenter.formatted_weight %></dd>
<dt>平均体脂肪率</dt><dd><%= @presenter.formatted_fat %></dd>
<dt>総カロリー</dt><dd><%= @presenter.formatted_calories %></dd>
<dt>通知</dt><dd><%= @presenter.notification_label %></dd>
</dl>
</div>
解決策2:ViewComponent の導入
# app/components/weekly_summary_card_component.rb
class WeeklySummaryCardComponent < ViewComponent::Base
def initialize(report:)
@report = report
end
def formatted_period
"#{@report.period_start} 〜 #{@report.period_end}"
end
def formatted_weight
@report.avg_weight_g ? "#{@report.avg_weight_g / 1000.0} kg" : "データなし"
end
def formatted_fat
@report.avg_body_fat_bp ? "#{@report.avg_body_fat_bp / 100.0}%" : "データなし"
end
def formatted_calories
@report.total_calories_kcal ? "#{@report.total_calories_kcal} kcal" : "データなし"
end
def notification_label
@report.notified_at ? "送信済み #{@report.notified_at.strftime('%m/%d %H:%M')}" : "未送信"
end
end
<%# app/components/weekly_summary_card_component.html.erb %>
<div class="weekly-summary-card">
<h3><%= formatted_period %></h3>
<dl>
<dt>平均体重</dt><dd><%= formatted_weight %></dd>
<dt>平均体脂肪率</dt><dd><%= formatted_fat %></dd>
<dt>総カロリー</dt><dd><%= formatted_calories %></dd>
<dt>通知</dt><dd><%= notification_label %></dd>
</dl>
</div>
ビューからは1行で呼び出せます。
<%# app/views/weekly_reports/show.html.erb (feature/15-view-component) %>
<h1>週次レポート</h1>
<%= render WeeklySummaryCardComponent.new(report: @report) %>
<%= link_to "一覧に戻る", weekly_reports_path %>
Before / After 比較
モデルのスリム化
# Before: モデルに表示メソッドが5つ
class WeeklyReport < ApplicationRecord
# ...データの永続化...
def formatted_weight = avg_weight_g ? "#{avg_weight_g / 1000.0} kg" : "データなし"
def formatted_fat = avg_body_fat_bp ? "#{avg_body_fat_bp / 100.0}%" : "データなし"
def formatted_calories = total_calories_kcal ? "#{total_calories_kcal} kcal" : "データなし"
def notification_label = notified_at ? "送信済み ..." : "未送信"
def formatted_period = "#{period_start} 〜 #{period_end}"
end
# After: モデルはデータのみ(表示メソッドが消える)
class WeeklyReport < ApplicationRecord
# データの永続化に集中
belongs_to :user
validates :period_start, :period_end, presence: true
after_commit :write_audit_log, on: [:create, :update]
# ...
end
ビューの呼び出し方の変化
<%# Before: @report に直接表示メソッドを呼ぶ(モデルにメソッドが必要) %>
<dt>平均体重</dt><dd><%= @report.formatted_weight %></dd>
<%# Presenter 導入後: @presenter 経由 %>
<dt>平均体重</dt><dd><%= @presenter.formatted_weight %></dd>
<%# ViewComponent 導入後: コンポーネント1行で済む %>
<%= render WeeklySummaryCardComponent.new(report: @report) %>
ViewComponent のユニットテスト
# RSpec でコンポーネントを単体テスト(ビュー全体のレンダリング不要)
RSpec.describe WeeklySummaryCardComponent, type: :component do
let(:report) do
build(:weekly_report,
avg_weight_g: 70_500,
avg_body_fat_bp: 1800,
total_calories_kcal: 300)
end
it "体重を kg 単位で表示する" do
render_inline(WeeklySummaryCardComponent.new(report: report))
expect(page).to have_css("dd", text: "70.5 kg")
end
it "体脂肪率を % 単位で表示する" do
render_inline(WeeklySummaryCardComponent.new(report: report))
expect(page).to have_css("dd", text: "18.0%")
end
it "未通知の場合「未送信」と表示する" do
render_inline(WeeklySummaryCardComponent.new(report: report))
expect(page).to have_css("dd", text: "未送信")
end
end
Presenter vs ViewComponent の使い分け
| Presenter | ViewComponent | |
|---|---|---|
| 責務 | データのフォーマット | UI の断片(HTML含む) |
| テスト | Ruby のユニットテスト | HTML レンダリングテスト |
| 再利用 | 複数ビューで @presenter を共有 |
render XxxComponent.new(...) で再利用 |
| 向いている場面 | 表示文字列の生成 | カード、バッジ、フォームなどのUIパーツ |
まとめ
Presenter のメリット
-
formatted_*などの表示メソッドをモデルから切り出し、モデルを薄く保つ -
delegateにより、モデルの属性に透過的にアクセスできる - Presenter 単体でテスト可能(DB 不要)
ViewComponent のメリット
- Ruby クラス + ERB テンプレートで UI をカプセル化
-
render_inlineでユニットテストが書ける(コントローラ・ルーティング不要) -
<%= render XxxComponent.new(...) %>で再利用性が高い - プロパティをコンストラクタに明示するため、コンポーネントの依存関係が明確
Presenter → ViewComponent への発展
Presenter は「フォーマット」に特化し、ViewComponent は「HTML 全体」を担当します。
最初は Presenter でシンプルに始め、UI が複雑になってきたら ViewComponent に昇格させるアプローチが実践的です。