NYC
skills/dchuk/rails_ai_agents/viewcomponent-patterns

viewcomponent-patterns

SKILL.md

ViewComponent Patterns for Rails 8

Overview

ViewComponents are Ruby objects for building reusable, testable view components:

  • Faster than partials (no partial lookup)
  • Unit testable without full request cycle
  • Encapsulate view logic with Ruby
  • Type-safe with explicit interfaces

TDD Workflow

ViewComponent Progress:
- [ ] Step 1: Write component test (RED)
- [ ] Step 2: Run test (fails - no component)
- [ ] Step 3: Generate component skeleton
- [ ] Step 4: Implement component
- [ ] Step 5: Run test (GREEN)
- [ ] Step 6: Add variants/slots if needed

Step 1: Component Test (RED)

# test/components/card_component_test.rb
require "test_helper"

class CardComponentTest < ViewComponent::TestCase
  test "renders the title" do
    render_inline(CardComponent.new(title: "Test Title"))
    assert_selector "h3", text: "Test Title"
  end

  test "renders content block" do
    render_inline(CardComponent.new(title: "Title")) { "Card content" }
    assert_text "Card content"
  end

  test "renders subtitle when provided" do
    render_inline(CardComponent.new(title: "Title", subtitle: "Subtitle"))
    assert_selector "p", text: "Subtitle"
  end

  test "does not render subtitle element when not provided" do
    render_inline(CardComponent.new(title: "Title"))
    assert_no_selector ".subtitle"
  end
end

Step 2-4: Implement Component

Base Component

# app/components/application_component.rb
class ApplicationComponent < ViewComponent::Base
  include ActionView::Helpers::TagHelper
  include ActionView::Helpers::NumberHelper

  def not_specified_span
    tag.span(I18n.t("components.common.not_specified"), class: "text-slate-400 italic")
  end
end

Basic Component

# app/components/card_component.rb
class CardComponent < ApplicationComponent
  def initialize(title:, subtitle: nil)
    @title = title
    @subtitle = subtitle
  end

  attr_reader :title, :subtitle

  def subtitle?
    subtitle.present?
  end
end
<%# app/components/card_component.html.erb %>
<div class="bg-white rounded-lg shadow p-6">
  <h3 class="text-lg font-semibold text-slate-900"><%= title %></h3>
  <% if subtitle? %>
    <p class="subtitle text-sm text-slate-500"><%= subtitle %></p>
  <% end %>
  <div class="mt-4">
    <%= content %>
  </div>
</div>

Common Patterns

Pattern 1: Status Badge

# app/components/badge_component.rb
class BadgeComponent < ApplicationComponent
  VARIANTS = {
    success: "bg-green-100 text-green-800",
    warning: "bg-yellow-100 text-yellow-800",
    error: "bg-red-100 text-red-800",
    info: "bg-blue-100 text-blue-800",
    neutral: "bg-slate-100 text-slate-800"
  }.freeze

  def initialize(text:, variant: :neutral)
    @text = text
    @variant = variant.to_sym
  end

  def call
    tag.span(
      @text,
      class: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium #{variant_classes}"
    )
  end

  private

  def variant_classes
    VARIANTS.fetch(@variant, VARIANTS[:neutral])
  end
end

Testing:

# test/components/badge_component_test.rb
require "test_helper"

class BadgeComponentTest < ViewComponent::TestCase
  test "renders success variant" do
    render_inline(BadgeComponent.new(text: "Active", variant: :success))
    assert_selector ".bg-green-100"
    assert_text "Active"
  end

  test "renders error variant" do
    render_inline(BadgeComponent.new(text: "Failed", variant: :error))
    assert_selector ".bg-red-100"
  end

  test "defaults to neutral variant" do
    render_inline(BadgeComponent.new(text: "Unknown"))
    assert_selector ".bg-slate-100"
  end
end

Pattern 2: Component with Slots

# app/components/card_component.rb
class CardComponent < ApplicationComponent
  renders_one :header
  renders_one :footer
  renders_many :actions

  def initialize(title: nil)
    @title = title
  end
end

Testing slots:

# test/components/card_component_test.rb
class CardComponentTest < ViewComponent::TestCase
  test "renders header slot" do
    render_inline(CardComponent.new) do |card|
      card.with_header { "Custom Header" }
    end

    assert_text "Custom Header"
  end

  test "renders multiple action slots" do
    render_inline(CardComponent.new) do |card|
      card.with_action { "Action 1" }
      card.with_action { "Action 2" }
    end

    assert_text "Action 1"
    assert_text "Action 2"
  end
end

Pattern 3: Collection Component

# app/components/event_card_component.rb
class EventCardComponent < ApplicationComponent
  with_collection_parameter :event

  def initialize(event:)
    @event = event
  end

  delegate :name, :event_date, :status, to: :@event

  def formatted_date
    return not_specified_span if event_date.nil?
    I18n.l(event_date, format: :long)
  end

  def status_badge
    render BadgeComponent.new(text: status.humanize, variant: status_variant)
  end

  private

  def status_variant
    case status.to_sym
    when :confirmed then :success
    when :cancelled then :error
    when :pending then :warning
    else :neutral
    end
  end
end

Testing collections:

# test/components/event_card_component_test.rb
class EventCardComponentTest < ViewComponent::TestCase
  test "renders single event" do
    event = events(:one)
    render_inline(EventCardComponent.new(event: event))
    assert_text event.name
  end

  test "renders collection" do
    events_list = [events(:one), events(:two)]
    render_inline(EventCardComponent.with_collection(events_list))
    assert_selector ".event-card", count: 2
  end
end

Pattern 4: Modal Component

# app/components/modal_component.rb
class ModalComponent < ApplicationComponent
  renders_one :trigger
  renders_one :title
  renders_one :footer

  def initialize(id:, size: :medium)
    @id = id
    @size = size
  end

  def size_classes
    case @size
    when :small then "max-w-md"
    when :medium then "max-w-lg"
    when :large then "max-w-2xl"
    when :full then "max-w-full mx-4"
    end
  end
end

Usage in Views

<%# Simple component %>
<%= render BadgeComponent.new(text: "Active", variant: :success) %>

<%# Component with block %>
<%= render CardComponent.new(title: "Stats") do %>
  <p>Content here</p>
<% end %>

<%# Component with slots %>
<%= render CardComponent.new do |card| %>
  <% card.with_header do %>
    <h2>Header</h2>
  <% end %>
  Content
<% end %>

<%# Collection %>
<%= render EventCardComponent.with_collection(@events) %>

Previews (Development)

# test/components/previews/badge_component_preview.rb
class BadgeComponentPreview < ViewComponent::Preview
  def success
    render BadgeComponent.new(text: "Active", variant: :success)
  end

  def error
    render BadgeComponent.new(text: "Failed", variant: :error)
  end
end

Access at: http://localhost:3000/rails/view_components

Checklist

  • Test written first (RED)
  • Extends ApplicationComponent
  • Uses slots for flexible content
  • Variants use constants (Open/Closed)
  • Tested with different inputs
  • Collection rendering tested
  • Preview created for development
  • All tests GREEN
Weekly Installs
2
First Seen
7 days ago
Installed on
opencode2
gemini-cli2
antigravity2
claude-code2
windsurf2
codex2