Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: ViewComponentで最初に作るのは「ダイアログコンポーネント」がおすすめ

最初に何をコンポーネント化するか

ViewComponentを初めて導入したとき、何をコンポーネント化するかで迷ったことはありませんか?

viewcomponent/view_component - GitHub

私も最初のうちはボタンを闇雲にコンポーネント化してみたり、フォームのフィールドをコンポーネント化してみたり、はたまたセレクトボックスをコンポーネント化してみたりと、ぺったんぺったん作ったり壊したりを何度も繰り返しました。

最初は「ダイアログ」をコンポーネント化してみよう

そして今は、アプリで最初に作るコンポーネントは「ダイアログボックス」が最適だと思っています。

Flashを使わないRailsアプリは今なら珍しくもありませんが、「ダイアログボックス」をまったく使わないWebアプリはかなり少ないと思います。

しかもダイアログボックスのスタイルはアプリ全体で共通化したいことが多いはずです。しかもダイアログボックスに表示するテキストの量に応じて的確に伸縮し、アラートダイアログではそれらしい警戒色も表示したいでしょう。

ダイアログボックスのスタイルは時間とともにアプリのあちこちでだんだん不統一になったりしがちなので、コンポーネント化によるありがたみが即座に実感できます。

当然ですが、最初から何もかもコンポーネント化する必要などありません。欲しいところから順にコンポーネント化していけばいいのです。それこそ最初はダイアログボックスのためだけにViewComponentを導入してもよいくらいです。

今なら(ほぼ)理想のダイアログボックスを実装できる

HTML: 「JavaScriptなし」で動く最新の多機能確認ダイアログを構築する(翻訳)

上の記事で詳しく説明されているように、2026年の現在では、JavaScriptを一切使わずに多機能でスタイリッシュなダイアログボックスを実装できます。これをViewComponentで実現しましょう。

ただ惜しくもSafariだけは現時点でclosedbyが未実装なため、ポリフィルでしのぐかJSでカバーするしかありません。😢

参考: "closedby" | Can I use... Support tables for HTML5, CSS3, etc

本記事ではJSなしを通したいので、ここでは涙をのんでSafariはサポート外としています。ご了承ください。🙏

デモアプリ

以下にデモアプリを置きました。
ダイアログコンポーネントはTailwind版(DialogComponent)と通常のCSS版(DialogCssComponent)の両方があります。

hachi8833/rails81_dialog_component - GitHub

セットアップせずにすぐ見てみたい人向けに、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

Claude Sonnet 4.6による指摘:

ダイアログでform_withを使う場合、Turboが有効だとフォーム送信がAjaxになって、サーバーからのリダイレクトレスポンスがTurboに横取りされることがあります。
ただ、これはアプリの設計次第でもあります。Turboを活かしてダイアログの結果をTurbo Streamsで処理したいケースでは、むしろturbo: falseは邪魔になります。

上では念のためdata: { turbo: false }でTurboをオフにしています。

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_buttonfalseにすると右上のクローズボックス[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_titlewith_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のスロットを活用すると、ダイアログボックスのスタイルを統一しながらビューで柔軟にカスタマイズできるようになります。

何もかも普通の引数で制御しようとすると引数が増えすぎて厄介なことになりますが、スロットのブロック渡しと引数をうまく使い分けることでコードを整頓できました。

こうやってダイアログコンポーネントを作ってみると、やっぱりボタンもコンポーネント化した方がいいかなという気持ちにだんだんなってくるかもしれません。私はなりました。そんな感じでコンポーネントを少しずつ増やしていくとよさそうです。

関連記事

Rails: パーシャルよりもViewComponentを選ぶべき理由(翻訳)

Rails: Tailwind CSSはコンポーネントに向いているCSSか?


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。