最初に何をコンポーネント化するか
ViewComponentを初めて導入したとき、何をコンポーネント化するかで迷ったことはありませんか?
私も最初のうちはボタンを闇雲にコンポーネント化してみたり、フォームのフィールドをコンポーネント化してみたり、はたまたセレクトボックスをコンポーネント化してみたりと、ぺったんぺったん作ったり壊したりを何度も繰り返しました。
最初は「ダイアログ」をコンポーネント化してみよう
そして今は、アプリで最初に作るコンポーネントは「ダイアログボックス」が最適だと思っています。
Flashを使わないRailsアプリは今なら珍しくもありませんが、「ダイアログボックス」をまったく使わないWebアプリはかなり少ないと思います。
しかもダイアログボックスのスタイルはアプリ全体で共通化したいことが多いはずです。しかもダイアログボックスに表示するテキストの量に応じて的確に伸縮し、アラートダイアログではそれらしい警戒色も表示したいでしょう。
ダイアログボックスのスタイルは時間とともにアプリのあちこちでだんだん不統一になったりしがちなので、コンポーネント化によるありがたみが即座に実感できます。
当然ですが、最初から何もかもコンポーネント化する必要などありません。欲しいところから順にコンポーネント化していけばいいのです。それこそ最初はダイアログボックスのためだけにViewComponentを導入してもよいくらいです。
今なら(ほぼ)理想のダイアログボックスを実装できる
上の記事で詳しく説明されているように、2026年の現在では、JavaScriptを一切使わずに多機能でスタイリッシュなダイアログボックスを実装できます。これをViewComponentで実現しましょう。
ただ惜しくもSafariだけは現時点でclosedbyが未実装なため、ポリフィルでしのぐかJSでカバーするしかありません。😢
参考: "closedby" | Can I use... Support tables for HTML5, CSS3, etc
本記事ではJSなしを通したいので、ここでは涙をのんでSafariはサポート外としています。ご了承ください。🙏
デモアプリ
以下にデモアプリを置きました。
ダイアログコンポーネントはTailwind版(DialogComponent)と通常のCSS版(DialogCssComponent)の両方があります。
セットアップせずにすぐ見てみたい人向けに、Perronで生成した静的ページのGitHub Pagesも用意しました↓。
DialogComponentで最新のダイアログ表示を実現
Gemfileに以下を追加してbundle installすれば準備完了です。
# Gemfile
gem "view_component"
gem "lookbook" # 好みでよい
以下を実行してDialogComponentをテンプレートから生成します。idは必須の引数です。
$ bin/rails g view_component:component Dialog id
create app/components/dialog_component.rb
invoke test_unit
create test/components/dialog_component_test.rb
invoke tailwindcss
create app/components/dialog_component.html.erb
ダイアログコンポーネントのコード
DialogComponentを以下のような感じで実装します。
# app/components/dialog_component.rb
class DialogComponent < ViewComponent::Base
renders_one :title
renders_one :description
renders_one :body
renders_one :footer
def initialize(
id:,
confirm_text: "確認",
cancel_text: "キャンセル",
confirm_form: nil,
closedby: "any",
show_close_button: true,
**options
)
@id = id
@confirm_text = confirm_text
@cancel_text = cancel_text
@confirm_form = confirm_form
@closedby = closedby
@show_close_button = show_close_button
@options = options
end
private
attr_reader :id, :confirm_text, :cancel_text, :confirm_form, :closedby, :show_close_button, :options
def title_id
"#{id}-title"
end
def description_id
"#{id}-desc"
end
def form_method
confirm_form.present? ? :post : :dialog
end
def form_url
confirm_form || "#"
end
def form_options
{
method: form_method,
url: form_url,
data: { turbo: false }
}
end
end
ViewComponentの「スロット」は優秀
冒頭のrenders_oneは最初何だろうと思っていたのですが、これこそがViewComponentのスロット(slot)と呼ばれるすぐれものの機能です。
renders_one :title
renders_one :description
renders_one :body
renders_one :footer
renders_oneで定義したスロットにはwith_titleのようにメソッドが生えてくるので、表示したいHTMLやERBを以下のようにブロックで渡すことで、通常の引数とは別にする形で自由に表示内容やスタイルをカスタマイズできます。
<%= render(DialogComponent.new(id: "example-dialog1")) do |c| %>
<% c.with_title do %>
c.with_title
<% end %>
<% c.with_description do %>
c.with_description
<% end %>
<% c.with_body do %>
c.with_body
<% end %>
<% c.with_footer do %>
c.with_footer
<% end %>
<% end %>
上を表示すると以下のダイアログになります。
c.with_footerブロックを渡さなければデフォルトのボタンが表示されます。
ブーリアンやボタン名のようなシンプルな指定はスロットを使わずに、普通に引数にしています。
def initialize(
id:,
confirm_text: "確認",
cancel_text: "キャンセル",
confirm_form: nil,
closedby: "any",
show_close_button: true,
**options
)
@id = id
@confirm_text = confirm_text
@cancel_text = cancel_text
@confirm_form = confirm_form
@closedby = closedby
@show_close_button = show_close_button
@options = options
end
confirm_formはデフォルトのnilだと単なるダイアログになりますが、post_create_pathなどのパスを渡すとPOSTのフォームになりますclosedbyに"none"を渡すと、ダイアログの外をクリックしたりEscキーを押してもダイアログが閉じなくなります。show_close_buttonをfalseにすると右上のクローズボックス[X]を非表示にできます。
ダイアログコンポーネントのビュー
対応するビューは以下のような感じで定義します。ここではTailwind CSSを使っていますが、普通のCSSでも構いません。
<!-- app/components/dialog_component.html.erb -->
<dialog id="<%= id %>"
class="rounded-lg border-none bg-white p-0 shadow-xl max-w-md w-full m-auto backdrop:bg-black/60 <%= options[:class] %>"
aria-labelledby="<%= title_id %>"
aria-describedby="<%= description_id %>"
closedby="<%= closedby %>">
<header class="relative p-4 border-b border-gray-200">
<hgroup>
<h2 id="<%= title_id %>" class="m-0 text-lg font-semibold text-gray-900"><%= title %></h2>
<% if description %>
<p id="<%= description_id %>" class="m-0 mt-1 text-sm text-gray-600"><%= description %></p>
<% end %>
</hgroup>
<% if show_close_button %>
<button type="button"
commandfor="<%= id %>"
command="close"
class="absolute right-4 top-4 text-gray-500 hover:text-gray-700 text-2xl leading-none"
aria-label="ダイアログを閉じる">×</button>
<% end %>
</header>
<%= form_with **form_options do |f| %>
<article class="p-4 text-sm text-gray-700">
<%= body %>
</article>
<footer class="flex items-center justify-end gap-2 p-4 border-t border-gray-200">
<% if footer %>
<%= footer %>
<% else %>
<button class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
type="submit"
formmethod="dialog"
formnovalidate
value="cancel"><%= cancel_text %></button>
<button class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
type="submit"
autofocus><%= confirm_text %></button>
<% end %>
</footer>
<% end %>
</dialog>
ダイアログを呼び出し側でカスタマイズする
<%= render(DialogComponent.new(id: "example-dialog0")) %>
試しにidだけを渡してレンダリングすると、以下のようにデフォルトの内容が表示されます。
次はwith_titleとwith_bodyでブロックを渡します。
<%= render(DialogComponent.new(id: "example-dialog2")) do |c| %>
<% c.with_title do %>
サンプルダイアログ2
<% end %>
<% c.with_body do %>
<p>
このダイアログは確認用です。
本当に実行してよろしいですか?
</p>
<% end %>
<% end %>
今度はwith_descriptionも渡します。
<%= render(DialogComponent.new(id: "example-dialog2")) do |c| %>
<% c.with_title do %>
サンプルダイアログ2
<% end %>
<% c.with_description do %>
確認ダイアログの例
<% end %>
<% c.with_body do %>
<p>
このダイアログは確認用です。
本当に実行してよろしいですか?
</p>
<% end %>
<% end %>
今度は少し凝ってみます。
ボタンをカスタマイズして「キャンセル」と赤い「削除する」ボタンにします。さらにクローズボックスを非表示にして、ダイアログの外をクリックしたりEscキーを押したりしてもダイアログが閉じないようにします。
<%= render(DialogComponent.new(id: "example-dialog3", closedby: "none", show_close_button: false)) do |c| %>
<% c.with_title do %>
サンプルダイアログ3
<% end %>
<% c.with_description do %>
ダイアログの外をクリックしてもEscキーを押しても閉じません
<% end %>
<% c.with_body do %>
<p>
<strong class="font-semibold">警告:</strong> この操作は取り消せません。
本当に削除しますか?
</p>
<% end %>
<% c.with_footer do %>
<button class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
type="submit"
formmethod="dialog"
formnovalidate
value="cancel">キャンセル</button>
<button class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700"
type="submit"
autofocus>削除する</button>
<% end %>
<% end %>
(もっともページを閉じてしまえばスキップされてしまいますが...)
まとめ
このように、ViewComponentのスロットを活用すると、ダイアログボックスのスタイルを統一しながらビューで柔軟にカスタマイズできるようになります。
何もかも普通の引数で制御しようとすると引数が増えすぎて厄介なことになりますが、スロットのブロック渡しと引数をうまく使い分けることでコードを整頓できました。
こうやってダイアログコンポーネントを作ってみると、やっぱりボタンもコンポーネント化した方がいいかなという気持ちにだんだんなってくるかもしれません。私はなりました。そんな感じでコンポーネントを少しずつ増やしていくとよさそうです。







Claude Sonnet 4.6による指摘:
上では念のため
data: { turbo: false }でTurboをオフにしています。