35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Rails ViewComponent: Reusable UI Components, Testing and Performance Beyond Partials

Rails ViewComponent: Reusable UI Components, Testing and Performance Beyond Partials

Roger Heykoop
Ruby on Rails, Performance
Rails ViewComponent replaces partials with testable, reusable UI components. Setup, slots, previews, performance benchmarks and migration patterns from a CTO.

A client showed me their app/views/shared/ directory last summer. It had 184 partials. Forty-one of them were called _card.html.erb in different subfolders, each one slightly different, each one used in three places, each one untested. When I asked the team how they decided which partial to use, the lead developer laughed and said “we grep.” That codebase was four years old and still shipping features, but the view layer had quietly become the most expensive area of the application to change. We rewrote it in Rails ViewComponent over six weeks. Render time dropped 18%, the design system finally had a single source of truth, and grepping _card.html.erb stopped being a job description.

After nineteen years of Rails I have a strong opinion about partials: they were the right answer for 2005 and they are the wrong answer for 2026. Rails ViewComponent — the gem GitHub extracted from their own monolith — replaces partials with plain Ruby objects you can test, document, and compose. This post is the case I make to teams who think the migration is not worth it: what ViewComponent is, when to use it, how to set it up, the patterns that matter, and the production numbers that justify the move.

What Rails ViewComponent Actually Is

Rails ViewComponent is a framework for building reusable view components as Ruby classes. Each component is a .rb file paired with a .html.erb template. The Ruby class is the contract — its initializer arguments are the “props” the component takes, and its public methods are the helpers the template can call. The template is dumb: no instance variables leaking from the controller, no half-initialized state, no implicit context.

The mental model that clicks for most teams is React-with-ERB. A ButtonComponent is invoked with arguments (render(ButtonComponent.new(label: "Save", variant: :primary))), it has a deterministic render output, and you can unit-test it without booting a controller. Where partials are essentially file-based templates with implicit locals, Rails ViewComponent components are explicit, isolated, and fast.

The performance comes from compilation. ViewComponent compiles its templates once at boot into method definitions on the class. Every render is a method call. Partials, by contrast, go through Action View’s lookup and template resolution on every render. For a complex page that renders the same partial 50 times, the difference is measurable — usually 5-15% on render time, sometimes more.

But the real win is not the milliseconds. It is what tests, previews, and explicit interfaces do to the cost of changing the UI a year from now.

When to Use Rails ViewComponent vs Partials

I do not migrate every partial. The line I draw with clients is simple: anything used in more than one place, or that has logic beyond rendering data, becomes a component. Everything else stays a partial.

Reach for Rails ViewComponent when:

  • The same UI element appears in three or more places (cards, buttons, badges, modals, tables, form rows).
  • The view has conditional logic that bloats the template (<% if user.admin? %> chains, role-based visibility, feature-flag branches).
  • You want to write a test for the rendered output — partials make that painful.
  • You are building a design system and need the components catalogued somewhere developers can discover them.
  • The view talks to a complex object and you keep finding helper methods that only this view uses.

Stay with partials when:

  • The view is rendered once on one page and is unlikely to be reused.
  • It is mostly static markup with a single instance variable.
  • You are early enough in the project that abstraction would be premature.

The mistake I see most often is going to either extreme. Either teams refuse to adopt ViewComponent at all because “partials work fine,” or they convert every single partial because “components are better.” Both are wrong. The cost-benefit lives in the middle.

Installing Rails ViewComponent

The gem is straightforward. On Rails 7.1+ or 8.0:

# Gemfile
gem "view_component"
bundle install
bin/rails generate view_component:install

The installer adds an autoload path for app/components/, configures previews, and creates test/components/ with a base test class. From there, generate your first component:

bin/rails generate component Button label variant

This creates three files:

app/components/button_component.rb
app/components/button_component.html.erb
test/components/button_component_test.rb

The component class:

# app/components/button_component.rb
class ButtonComponent < ViewComponent::Base
  VARIANTS = %i[primary secondary danger ghost].freeze

  def initialize(label:, variant: :primary, type: "button", **options)
    @label = label
    @variant = variant
    @type = type
    @options = options

    raise ArgumentError, "Invalid variant: #{variant}" unless VARIANTS.include?(variant)
  end

  def css_classes
    base = "inline-flex items-center px-4 py-2 rounded font-medium transition-colors"
    "#{base} #{variant_classes}"
  end

  private

  attr_reader :label, :variant, :type, :options

  def variant_classes
    case variant
    when :primary   then "bg-blue-600 text-white hover:bg-blue-700"
    when :secondary then "bg-gray-200 text-gray-900 hover:bg-gray-300"
    when :danger    then "bg-red-600 text-white hover:bg-red-700"
    when :ghost     then "bg-transparent text-gray-700 hover:bg-gray-100"
    end
  end
end

The template:

<%# app/components/button_component.html.erb %>
<button type="<%= type %>" class="<%= css_classes %>" <%= options.to_html_attributes %>>
  <%= label %>
</button>

And it renders from any view or another component:

<%= render(ButtonComponent.new(label: "Save", variant: :primary, data: { turbo_method: :post })) %>

The first thing engineers notice is that the template has no if statements, no helper calls, no instance variables from controllers. Every input is explicit. Every output is deterministic.

Slots: The Pattern That Replaces Yields

Partials handle multi-section layouts with content_for and yield. ViewComponent handles them with slots, and slots are the single feature that makes the migration worth it for complex UI.

A card component with header, body, and footer:

# app/components/card_component.rb
class CardComponent < ViewComponent::Base
  renders_one :header
  renders_one :footer
  renders_many :actions, "ButtonComponent"

  def initialize(variant: :default)
    @variant = variant
  end
end
<%# app/components/card_component.html.erb %>
<div class="card card-<%= @variant %>">
  <% if header? %>
    <div class="card-header"><%= header %></div>
  <% end %>

  <div class="card-body">
    <%= content %>
  </div>

  <% if actions.any? %>
    <div class="card-actions">
      <% actions.each do |action| %>
        <%= action %>
      <% end %>
    </div>
  <% end %>

  <% if footer? %>
    <div class="card-footer"><%= footer %></div>
  <% end %>
</div>

Calling it:

<%= render(CardComponent.new(variant: :elevated)) do |card| %>
  <% card.with_header do %>
    <h2>Order #<%= @order.id %></h2>
  <% end %>

  <p>Placed <%= time_ago_in_words(@order.created_at) %> ago.</p>

  <% card.with_action(label: "Refund", variant: :danger) %>
  <% card.with_action(label: "Print", variant: :secondary) %>

  <% card.with_footer do %>
    Total: <%= number_to_currency(@order.total) %>
  <% end %>
<% end %>

Two things to notice. First, renders_many :actions, "ButtonComponent" lets the slot accept either content blocks or constructor arguments for another component. Calling card.with_action(label:, variant:) is sugar for “build a ButtonComponent with these arguments and add it to the slot.” Second, header? and footer? are auto-generated predicates that let the template degrade gracefully when slots are empty.

Slots eliminate the entire content_for/yield dance, and they make complex layouts composable in a way that partials never managed.

Previews: The Feature That Sells the Migration

The reason I can sell Rails ViewComponent to skeptical teams is view_component/previews. Every component gets a Storybook-style preview page at /rails/view_components. You write a preview class once and your design system has a living catalog.

# test/components/previews/button_component_preview.rb
class ButtonComponentPreview < ViewComponent::Preview
  def primary
    render(ButtonComponent.new(label: "Save", variant: :primary))
  end

  def secondary
    render(ButtonComponent.new(label: "Cancel", variant: :secondary))
  end

  def danger
    render(ButtonComponent.new(label: "Delete account", variant: :danger))
  end

  def with_long_label
    render(ButtonComponent.new(label: "Save and continue to next step", variant: :primary))
  end

  # @param label text
  # @param variant select [primary, secondary, danger, ghost]
  def playground(label: "Click me", variant: :primary)
    render(ButtonComponent.new(label: label, variant: variant.to_sym))
  end
end

Visit /rails/view_components/button_component/primary and you see the rendered button. The playground example with @param annotations gives you Lookbook-style live controls if you install the lookbook gem alongside ViewComponent.

I have shipped this to every Rails 8 client in the last two years. It changes the conversation between design and engineering. Designers can see what exists. Engineers can find what to reuse. Code review on UI changes goes from “looks fine” to “open the preview, click variants, ship it.”

Testing Rails ViewComponent

This is the feature that pays the rent. Components are unit-testable in isolation:

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

class ButtonComponentTest < ViewComponent::TestCase
  test "renders primary variant by default" do
    render_inline(ButtonComponent.new(label: "Save"))

    assert_selector "button.bg-blue-600", text: "Save"
  end

  test "renders danger variant" do
    render_inline(ButtonComponent.new(label: "Delete", variant: :danger))

    assert_selector "button.bg-red-600", text: "Delete"
  end

  test "raises on invalid variant" do
    assert_raises(ArgumentError) do
      ButtonComponent.new(label: "Save", variant: :neon)
    end
  end

  test "passes data attributes through" do
    render_inline(ButtonComponent.new(label: "Save", data: { turbo_method: :post }))

    assert_selector "button[data-turbo-method='post']"
  end
end

These tests run in milliseconds. No browser, no controller, no database. You can write twenty of them in the time it takes to write one Capybara system test. For a design system that has eighty components, this is the difference between testable UI and untestable UI.

I run component tests as part of the regular Minitest suite — see my approach to fast Minitest fixtures for how I keep the rest of the suite fast enough that adding component tests does not hurt.

Performance: Real Benchmarks

I have measured Rails ViewComponent vs partials on three production apps. The pattern is consistent.

A page that renders 200 instances of the same component in a list:

  • Partial version: 142 ms server render time, p50.
  • ViewComponent version: 121 ms server render time, p50.
  • Improvement: 14.8%.

A complex dashboard with 40 mixed components per page:

  • Partial version: 89 ms p50, 156 ms p95.
  • ViewComponent version: 76 ms p50, 128 ms p95.
  • Improvement: 14.6% p50, 18% p95.

A simple page with three components:

  • Partial version: 22 ms.
  • ViewComponent version: 21 ms.
  • Improvement: noise.

The takeaway: ViewComponent is meaningfully faster on render-heavy pages and indistinguishable on light ones. It will not turn a slow page fast — for that, see my N+1 query guide — but it will not slow you down, and the architectural wins compound regardless.

Migration Strategy: How I Roll This Out

I never rewrite the entire view layer in one pull request. Three weeks of strangler-pattern migration is what I recommend to clients:

Week 1: pick the high-traffic, high-reuse partials. Buttons, badges, cards, form rows. Convert one at a time. Add component tests as you go. Keep the partial in place, render the component from it, prove behavior is identical, then update callers in batches.

Week 2: tackle layout-heavy partials with slots. Modals, dropdowns, tabs, accordions. These are where slots earn their keep. The component versions usually end up shorter than the partial versions because all the content_for plumbing disappears.

Week 3: build the design system index. Write previews for every component. Stand up Lookbook. Have one design review session looking at the full preview page. This is the moment teams realize they have three slightly different button styles and decide which one to keep.

I do not migrate every partial. Anything that is rendered once and unlikely to change does not need to become a component. The 80/20 rule holds — 20% of the partials cause 80% of the change cost.

This pattern is the same one I use for extracting concerns sparingly: abstract what is reused, leave what is local.

Production Patterns and Gotchas

Do not put Active Record queries in components. Components are view objects. They take data, they render markup. If a UserCardComponent queries user.posts.recent inside its initializer, you have moved your N+1 problem from the controller to the component and made it harder to find. Pass pre-loaded data in.

Use polymorphic helpers carefully. ViewComponent supports link_to, form_with, and the rest of Action View’s helpers, but not all custom helpers — your current_user, for instance, is not automatically available. Either pass it in as an argument or override def helpers to expose what you need. I prefer passing it in; explicit beats implicit every time.

Slots have a cost. renders_many allocates an array per render. For a component rendered hundreds of times in a tight loop, prefer constructor arguments over slots when you can. This rarely matters, but it does matter on render-heavy admin tables.

Component generators are your friend. I always extend the default generator to scaffold a preview file alongside the component. See my custom Rails generators guide for how I wire that up — five minutes once, hours saved over the life of a project.

Naming matters more than you think. I use the Component suffix on every class (ButtonComponent, not Button) and namespace by domain when the design system grows beyond ~30 components: Forms::FieldComponent, Layout::PageHeaderComponent. Flat namespaces stop scaling around 40 components.

When Rails ViewComponent Is Not the Answer

If your app is mostly form pages and one-off views, ViewComponent is overkill. Stick with partials. If you are building a design system catalog for a non-Rails frontend (React, Vue), use Storybook directly. If you need server-rendered components shared across multiple Rails apps, ViewComponent works but you may also want to look at gem-packaging your components — that is a longer conversation.

For everyone else — most Rails apps with a real UI surface area — Rails ViewComponent is the cheapest architectural upgrade I know. It costs three weeks, pays back in render time and tests, and turns the view layer from the most expensive area to change into one of the easier ones.

FAQ

Is Rails ViewComponent compatible with Hotwire and Turbo?

Yes, completely. Components render the same HTML as partials and work transparently with Turbo Frames, Turbo Streams, and Stimulus. I render Turbo Stream broadcasts from components all the time — the syntax is the same as rendering them from partials, the underlying HTML is what Turbo cares about.

Do I need to migrate everything to use ViewComponent?

No. ViewComponent and partials coexist in the same view. I usually leave 60-70% of partials in place and only migrate the high-reuse, high-change ones. Pick the partials where the cost of inconsistency is highest — buttons, cards, modals, form fields — and leave page-specific markup as partials.

How does ViewComponent compare to Phlex?

Phlex is a more recent Ruby-native templating library where the whole component is one Ruby class — no .html.erb file. Phlex is faster than ViewComponent on benchmarks and the all-Ruby DSL feels cleaner if you like writing markup in Ruby. ViewComponent is more mature, has better preview tooling, and lets designers edit .html.erb directly. For most teams, ViewComponent is still the right default. If your team is comfortable writing markup in Ruby and you do not need designer-editable templates, Phlex is worth a serious look.

What about performance — is ViewComponent always faster than partials?

On render-heavy pages it is 10-20% faster because templates compile once into Ruby methods. On simple pages with a handful of partials the difference is in the noise. Do not migrate to ViewComponent for performance alone — migrate for testability, slots, and the design system catalog. The performance is a side benefit.

Need help adopting Rails ViewComponent or building a design system inside a Rails 8 app? TTB Software does this work as a fractional CTO engagement. We have been shipping Rails for nineteen years.

#rails-viewcomponent #viewcomponent-vs-partials #rails-ui-components #viewcomponent-testing #viewcomponent-performance #ruby-on-rails #rails-8
R

About the Author

Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation