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アプリで学ぶデザインパターン - Presenter + ViewComponent (7/7)

0
Last updated at Posted at 2026-03-16

はじめに

この記事は、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

問題点

  1. 関心の混在: モデルは「データの保持・永続化」が責務。
    「表示フォーマット」はビューの関心事なのにモデルに混入している
  2. モデルの肥大化: ビジネスロジックに加えて表示メソッドが増え続ける
  3. 再利用の困難さ: 表示ロジックをテストするためにモデル全体が必要
  4. ビューテンプレートの断片化: 複雑な 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 に昇格させるアプローチが実践的です。

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?