15
2

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】パーシャルとViewComponent、どう使い分ける? 「FES READY」の実例で整理してみた

15
Last updated at Posted at 2026-01-06

はじめに

こんにちは!hinata です。

Railsでビューを書いていると、
「とりあえずパーシャルに切り出す」 こと、よくありませんか?

最初はスッキリしていたはずなのに、

  • 画面ごとの差分が増える
  • if / elsif / else のような条件分岐が増える
  • 引数が増えて意味が分からなくなる

といった状態になっていき、
気づいたら 「読むのがつらいERB」 になっている。

この記事では、私が開発している
音楽フェス準備アプリ FES READY の実例をもとに、

  • パーシャルが限界を迎えた瞬間
  • ViewComponentに置き換えて楽になった理由

を、 Before / After コード付きで紹介します。

⚠️ 注意

本記事の内容は、Rails初学者である筆者が
実際に開発中につまずいたポイントと、その時点での理解をもとにまとめたものです。

設計や実装については、より良い書き方・考え方が存在する可能性がありますが、
「初学者がどう悩み、どう整理したか」という観点で読んでいただけると嬉しいです。

fesready_ogp.png

🔗 FES READY


パーシャルとは?

Railsの ビューの一部分を切り出す仕組み です。

<!-- app/views/shared/_tag.html.erb -->
<span class="tag"><%= label %></span>
<%= render "shared/tag", label: "NEW" %>

軽くて書きやすい一方、
状態や分岐が増えると一気に読みづらくなる のが弱点です。

パーシャルで始めた結果、こうなった

FES READYには
「タイムテーブル表示」 という画面があります。

この画面では
「1ステージ分の縦列UI」を共通パーツとして使っています。

使われる画面は以下の3つ。

  • 公開タイムテーブル
  • マイタイムテーブル編集
  • マイタイムテーブル閲覧

一見すると同じUIですが、
振る舞いは微妙に違います。

実際のFES READYの画面

  • 公開タイムテーブル画面

image.png

  • マイタイムテーブル編集画面

image.png

  • マイタイムテーブル閲覧画面

image.png

  • タイムテーブル公開画面 :クリックするとリンク表示
  • マイタイムテーブル編集画面 :選択してるものにチェックボックス
  • マイタイムテーブル閲覧画面 :選択済み/未選択 の色分け + リンク表示

これを 1つのパーシャルで吸収しようとした結果 がこちらです。

Before:パーシャルが肥大化した状態

※ 以降のコードは、説明を分かりやすくするために
実際の実装を簡略化した 擬似コード です。
(変数名・引数・処理の一部を省略しています)

<!-- app/views/timetables/_stage_column.html.erb -->
<div class="col">
  <div class="header"><%= stage.name %></div>

  <% performances.each do |performance| %>
    <% if mode == :picker %>
      <label>
        <%= check_box_tag "stage_performance_ids[]", performance.id %>
        <%= performance.artist.name %>
      </label>

    <% elsif mode == :my_timetable %>
      <div class="<%= selected_ids.include?(performance.id) ? 'selected' : 'unselected' %>">
        <%= performance.artist.name %>
      </div>

    <% else %>
      <%= link_to performance.artist.name, artist_path(performance.artist) %>
    <% end %>
  <% end %>
</div>

問題点

  • mode による分岐が増え続ける
  • どの画面用の処理か分かりにくい
  • UI変更時に全画面へ影響する恐怖

正直、

「これ、もうパーシャルの責務じゃないな…」

と思いました。

そこで ViewComponent を使う

ViewComponentは
UIを 「クラス + テンプレート」 として定義する仕組みです。

  • 状態は initialize に集約
  • 表示はテンプレートに集中
  • 画面差分はクラスで表現

条件分岐をERBから追い出せる のが最大のメリットです。

FES READYでの設計方針

タイムテーブル列を
Base + 子クラス構成 に分割しました。

クラス 役割
BaseComponent 共通レイアウト(列の枠・ヘッダー・ブロック配置など)
PublicComponent 公開タイムテーブル用(リンク表示)
MyTimetablePickerComponent マイタイムテーブル編集用(チェックボックス表示)
MyTimetableViewComponent マイタイムテーブル閲覧用(選択済みを色分け表示)

Base:共通レイアウトだけを書く

# app/components/timetables/columns/base_component.rb
class Timetables::Columns::BaseComponent < ViewComponent::Base
  def initialize(stage:, performances:, time_markers:, timeline_layout:)
    @stage = stage
    @performances = Array(performances)
    @time_markers = Array(time_markers)
    @timeline_layout = timeline_layout
  end

  private

  attr_reader :stage, :performances, :time_markers, :timeline_layout

  def render_block(_performance, _block)
    raise NotImplementedError
  end
end
<!-- app/components/timetables/columns/base_component.html.erb -->
<div class="column">
  <div class="header"><%= stage.name %></div>

  <% performance_blocks.each do |performance, block| %>
    <%= render_block(performance, block) %>
  <% end %>
</div>

子クラス:継承して画面ごとの差分だけを書く

公開タイムテーブル用(リンク)

# app/components/timetables/columns/public_component.rb
class Timetables::Columns::PublicComponent < Timetables::Columns::BaseComponent
  def render_block(performance, _block)
    link_to performance.artist.name, artist_path(performance.artist)
  end
end

マイタイムテーブル編集用(チェックボックス)

# app/components/timetables/columns/my_timetable_picker_component.rb
class Timetables::Columns::MyTimetablePickerComponent < Timetables::Columns::BaseComponent
  def render_block(performance, _block)
    check_box_tag(
      "stage_performance_ids[]",
      performance.id
    )
  end
end

マイタイムテーブル閲覧用(選択済みを色分け)

# app/components/timetables/columns/my_timetable_view_component.rb
class Timetables::Columns::MyTimetableViewComponent < Timetables::Columns::BaseComponent
  def initialize(stage:, performances:, time_markers:, timeline_layout:, selected_ids:)
    super(stage: stage, performances: performances, time_markers: time_markers, timeline_layout: timeline_layout)
    @selected_ids = Array(selected_ids)
  end

  private

  attr_reader :selected_ids

  def render_block(performance, _block)
    classes = selected_ids.include?(performance.id) ? "selected" : "unselected"
    content_tag(:div, performance.artist.name, class: classes)
  end
end

ビューからの呼び出し例

※ 編集・閲覧ページも同様に、それぞれ対応する Component を render しています。

タイムテーブル公開ページ

<%= render Timetables::Columns::PublicComponent.new(
      stage: stage,
      performances: performances,
      time_markers: time_markers,
      timeline_layout: layout
    ) %>

置き換えてよかった点

  • 共通レイアウトと画面差分の責務が分離された
  • 「このクラスはこの画面用」が一目で分かる
  • 公開ページだけUI変更、が怖くなくなった

まとめ

  • 小さいUIはパーシャルで十分
  • 分岐・状態が増えたらViewComponentを検討する
  • 画面差分が明確なUIほど効果が大きい

パーシャルが
「ちょっと読みづらいな…」
と思った瞬間が コンポーネント化のサイン でした。

15
2
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
15
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?