ViewComponent Guide

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

Overview

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

Directory Structure

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
    └── ...

Base Classes

ApplicationComponent

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)

Railsboot::Component

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

⚠️ CRITICAL: Block-Capturing Helpers

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

The Problem

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

The Solution

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

Safe vs Unsafe Helpers

Helper Can Delegate? Reason
fa_icon ✅ Yes No block support
image_tag ✅ Yes No block support
number_to_currency ✅ Yes No block support
cms_link ✅ Yes No block support
strip_tags ✅ Yes No block support
link_to ❌ No Accepts blocks
content_tag ❌ No Accepts blocks
tag ❌ No Accepts blocks (Rails 5+)
form_with ❌ No Accepts blocks

Creating a New Component

1. Generate the component

bin/rails generate component Www::MyFeature title

This creates:

  • app/components/www/my_feature_component.rb
  • app/components/www/my_feature_component.html.erb

2. Define the Ruby class

# 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

3. Create the template

<%# 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>

4. Render the component

<%# In any view %>
<%= render Www::MyFeatureComponent.new(title: "Hello") do %>
  <p>This is the block content</p>
<% end %>

Common Patterns

Using Slots

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 %>

Conditional Rendering

Use render? to conditionally render a component:

class Www::AlertComponent < ApplicationComponent
  def initialize(message:)
    @message = message
  end

  def render?
    @message.present?
  end
end

Accessing Helpers

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

Testing Components

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

References