はじめに
こんにちは!hinata です。
Railsでビューを書いていると、
「とりあえずパーシャルに切り出す」 こと、よくありませんか?
最初はスッキリしていたはずなのに、
- 画面ごとの差分が増える
- if / elsif / else のような条件分岐が増える
- 引数が増えて意味が分からなくなる
といった状態になっていき、
気づいたら 「読むのがつらいERB」 になっている。
この記事では、私が開発している
音楽フェス準備アプリ FES READY の実例をもとに、
- パーシャルが限界を迎えた瞬間
- ViewComponentに置き換えて楽になった理由
を、 Before / After コード付きで紹介します。
⚠️ 注意
本記事の内容は、Rails初学者である筆者が
実際に開発中につまずいたポイントと、その時点での理解をもとにまとめたものです。設計や実装については、より良い書き方・考え方が存在する可能性がありますが、
「初学者がどう悩み、どう整理したか」という観点で読んでいただけると嬉しいです。
🔗 FES READY
- アプリURL:https://fesready.com
- GitHub:https://github.com/hinata7777/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の画面
- 公開タイムテーブル画面
- マイタイムテーブル編集画面
- マイタイムテーブル閲覧画面
- タイムテーブル公開画面 :クリックするとリンク表示
- マイタイムテーブル編集画面 :選択してるものにチェックボックス
- マイタイムテーブル閲覧画面 :選択済み/未選択 の色分け + リンク表示
これを 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ほど効果が大きい
パーシャルが
「ちょっと読みづらいな…」
と思った瞬間が コンポーネント化のサイン でした。



