# Gemfile
gem 'view_component'
# app/components/button_component.rb
class ButtonComponent < ViewComponent::Base
VARIANTS = %w[primary secondary danger].freeze
SIZES = %w[small medium large].freeze
def initialize(variant: 'primary', size: 'medium', type: 'button', **options)
@variant = variant
@size = size
@type = type
@options = options
end
def call
content_tag :button, content, class: classes, type: @type, **@options
end
private
def classes
[
'btn',
"btn-#{@variant}",
"btn-#{@size}",
@options[:class]
].compact.join(' ')
end
end
# app/components/button_component.html.erb
<button class="<%= classes %>" type="<%= @type %>">
<%= content %>
</button>
# Using in views
<%= render ButtonComponent.new(variant: 'primary', size: 'large') do %>
Click Me
<% end %>
<%= render ButtonComponent.new(variant: 'danger', type: 'submit') do %>
Delete Account
<% end %>
# Component with slots
class CardComponent < ViewComponent::Base
renders_one :header
renders_one :footer
renders_many :actions
def initialize(title: nil)
@title = title
end
end
# app/components/card_component.html.erb
<div class="card">
<% if header? %>
<div class="card-header">
<%= header %>
</div>
<% end %>
<div class="card-body">
<%= content %>
</div>
<% if footer? %>
<div class="card-footer">
<%= footer %>
</div>
<% end %>
<% if actions? %>
<div class="card-actions">
<% actions.each do |action| %>
<%= action %>
<% end %>
</div>
<% end %>
</div>
# Using card with slots
<%= render CardComponent.new do |card| %>
<% card.with_header do %>
<h3>Card Title</h3>
<% end %>
<p>Card content here</p>
<% card.with_footer do %>
<small>Updated 2 hours ago</small>
<% end %>
<% card.with_action do %>
<%= link_to "Edit", edit_path %>
<% end %>
<% card.with_action do %>
<%= link_to "Delete", delete_path %>
<% end %>
<% end %>
# Component with helpers
class UserAvatarComponent < ViewComponent::Base
def initialize(user:, size: 'medium')
@user = user
@size = size
end
def call
image_tag avatar_url, class: "avatar avatar-#{@size}", alt: @user.name
end
private
def avatar_url
@user.avatar_url.presence || default_avatar_url
end
def default_avatar_url
helpers.asset_path('default-avatar.png')
end
end
# Component with collections
class PostListComponent < ViewComponent::Base
def initialize(posts:)
@posts = posts
end
def render?
@posts.any?
end
end
# app/components/post_list_component.html.erb
<div class="post-list">
<%= render(PostComponent.with_collection(@posts)) %>
</div>
# Individual post component
class PostComponent < ViewComponent::Base
with_collection_parameter :post
def initialize(post:)
@post = post
end
end
# Component previews for development
# test/components/previews/button_component_preview.rb
class ButtonComponentPreview < ViewComponent::Preview
def default
render ButtonComponent.new do
"Default Button"
end
end
def primary
render ButtonComponent.new(variant: 'primary', size: 'large') do
"Primary Button"
end
end
def all_variants
render_with_template
end
end
# test/components/previews/button_component_preview/all_variants.html.erb
<% ButtonComponent::VARIANTS.each do |variant| %>
<div style="margin: 10px;">
<%= render ButtonComponent.new(variant: variant) do %>
<%= variant.titleize %> Button
<% end %>
</div>
<% end %>
# Access previews at /rails/view_components
# Testing components
require 'test_helper'
class ButtonComponentTest < ViewComponent::TestCase
def test_renders_button
render_inline(ButtonComponent.new) { "Click me" }
assert_selector "button.btn", text: "Click me"
end
def test_applies_variant_class
render_inline(ButtonComponent.new(variant: 'primary')) { "Submit" }
assert_selector "button.btn-primary"
end
def test_applies_size_class
render_inline(ButtonComponent.new(size: 'large')) { "Big Button" }
assert_selector "button.btn-large"
end
end
# RSpec testing
RSpec.describe CardComponent, type: :component do
it "renders card with header and footer" do
render_inline(CardComponent.new) do |card|
card.with_header { "Title" }
card.with_footer { "Footer" }
"Content"
end
expect(page).to have_css('.card-header', text: 'Title')
expect(page).to have_css('.card-body', text: 'Content')
expect(page).to have_css('.card-footer', text: 'Footer')
end
end
# Polymorphic components
class AlertComponent < ViewComponent::Base
TYPES = {
success: { icon: '✓', color: 'green' },
error: { icon: '✗', color: 'red' },
warning: { icon: '⚠', color: 'yellow' },
info: { icon: 'ℹ', color: 'blue' }
}.freeze
def initialize(type: :info, dismissible: false)
@type = type
@dismissible = dismissible
end
def icon
TYPES[@type][:icon]
end
def color_class
"alert-#{TYPES[@type][:color]}"
end
end
ViewComponent brings component architecture to Rails views. Components encapsulate markup, logic, and tests in Ruby classes. I use ViewComponents for reusable UI elements—buttons, cards, modals, alerts. Components accept parameters via initializer, keeping views clean. Previews enable visual development—see all component variants without running app. Testing components is fast—unit tests without controllers or integration setup. ViewComponents render faster than partials—compiled to Ruby methods. Slots allow flexible content composition—header, body, footer. Components support variant rendering for different contexts. Understanding when to use components vs. partials improves architecture. ViewComponents are Rails' answer to React components, keeping logic server-side.