Skip to content

ViewComponent Guide

This project uses ViewComponent for building reusable, testable UI components.

ViewComponent is a framework for building view components in Rails. Components are Ruby objects that encapsulate view logic and templates, making them easier to test and reuse than partials.

Version: 3.22.0

app/components/
├── application_component.rb # Base class for all components
├── railsboot/ # Railsboot UI component library
│ └── component.rb
└── www/ # Website-specific components
├── product_card_component.rb
├── product_card_component.html.erb
└── ...

All custom components should inherit from ApplicationComponent:

class MyComponent < ApplicationComponent
def initialize(title:)
@title = title
end
end

ApplicationComponent provides:

  • Delegated helpers: fa_icon, image_tag, image_asset_tag, number_to_currency, number_with_delimiter, cms_link, post_url, post_path, strip_tags
  • Utility method: fetch_or_fallback(argument, constant, fallback)

Components from the Railsboot UI library inherit from Railsboot::Component. These are pre-built UI components with consistent styling.

Do NOT rely on ApplicationComponent delegation for helpers that accept blocks!

When you delegate a block-accepting helper (like link_to, content_tag, tag), it routes through the helpers proxy which breaks ViewComponent’s block capture mechanism.

Symptoms of this bug:

  • Block content renders BEFORE the wrapper element
  • Raw HTML tags (</div>, </span>) appear as visible text
  • Escaped HTML inside elements

Components that need link_to, content_tag, tag, or other block-accepting helpers must include the ActionView helper modules directly:

class Www::MyComponent < ApplicationComponent
include ActionView::Helpers::TagHelper
include ActionView::Helpers::UrlHelper
# Now link_to with blocks works correctly in the template:
# <%= link_to some_path, class: 'btn' do %>
# Click me <%= fa_icon('arrow-right') %>
# <% end %>
end
HelperCan Delegate?Reason
fa_icon✅ YesNo block support
image_tag✅ YesNo block support
number_to_currency✅ YesNo block support
cms_link✅ YesNo block support
strip_tags✅ YesNo block support
link_to❌ NoAccepts blocks
content_tag❌ NoAccepts blocks
tag❌ NoAccepts blocks (Rails 5+)
form_with❌ NoAccepts blocks
Terminal window
bin/rails generate component Www::MyFeature title

This creates:

  • app/components/www/my_feature_component.rb
  • app/components/www/my_feature_component.html.erb
app/components/www/my_feature_component.rb
class Www::MyFeatureComponent < ApplicationComponent
# Include these if you need link_to or content_tag with blocks
include ActionView::Helpers::TagHelper
include ActionView::Helpers::UrlHelper
def initialize(title:, description: nil)
@title = title
@description = description
end
private
attr_reader :title, :description
end
<%# app/components/www/my_feature_component.html.erb %>
<div class="my-feature">
<h2><%= title %></h2>
<% if description.present? %>
<p><%= description %></p>
<% end %>
<%= content %> <%# Renders block content passed to the component %>
</div>
<%# In any view %>
<%= render Www::MyFeatureComponent.new(title: "Hello") do %>
<p>This is the block content</p>
<% end %>

Slots allow you to pass multiple content blocks to a component:

class Www::CardComponent < ApplicationComponent
renders_one :header
renders_many :actions
def initialize(title:)
@title = title
end
end
<%# Template %>
<div class="card">
<div class="card-header">
<%= header %>
</div>
<div class="card-body">
<%= content %>
</div>
<% if actions? %>
<div class="card-footer">
<% actions.each do |action| %>
<%= action %>
<% end %>
</div>
<% end %>
</div>
<%# Usage %>
<%= render Www::CardComponent.new(title: "My Card") do |c| %>
<% c.with_header do %>
<h3>Custom Header</h3>
<% end %>
<p>Card body content</p>
<% c.with_action do %>
<%= link_to "Edit", edit_path %>
<% end %>
<% end %>

Use render? to conditionally render a component:

class Www::AlertComponent < ApplicationComponent
def initialize(message:)
@message = message
end
def render?
@message.present?
end
end
class Www::MyComponent < ApplicationComponent
def formatted_price
# Delegated helpers can be called directly
number_to_currency(@price)
end
def some_link
# For non-delegated helpers, use the helpers proxy
helpers.some_custom_helper
end
end

Components can be tested in isolation:

test/components/www/my_feature_component_test.rb
require "test_helper"
class Www::MyFeatureComponentTest < ViewComponent::TestCase
def test_renders_title
render_inline(Www::MyFeatureComponent.new(title: "Hello"))
assert_selector "h2", text: "Hello"
end
def test_renders_block_content
render_inline(Www::MyFeatureComponent.new(title: "Hello")) do
"Block content"
end
assert_text "Block content"
end
end