Rethinking Rails Views with ViewComponents

Most long lived Rails applications have a secret garden that slowly overgrows with weeds. It is not the models or the controllers. It is the app/views directory.

We start with good intentions. We extract duplication into partials. We use helpers to encapsulate logic. But over time the boundaries blur. Partials call other partials. Instance variables from controllers magically appear deep inside a nested view. Logic that belongs in a presenter finds its way into an ERB file with ugly scriptlet tags. Testing these views becomes a slow integration test that requires a full request cycle.

There is a simple reason for this. We have not been treating our views as what they are. They are code. And code needs structure boundaries and tests to stay healthy.

This is the problem that ViewComponents solve. The idea is so simple it feels obvious in retrospect. Treat chunks of your UI as real Ruby objects.

What is a ViewComponent

A ViewComponent is a Ruby object that renders a template. That is it. It lives in app/components and has two parts. The first is a Ruby file and the second is a template file.

Imagine you have a user avatar that appears all over your site. It needs a user object and maybe a size.

A component might look like this:

# app/components/user_avatar_component.rb
class UserAvatarComponent < ViewComponent::Base
  def initialize(user:, size:)
    @user = user
    @size = size
  end

  def image_url
    @user.avatar_url(size: @size)
  end
end

And its template would be here:

<%# app/components/user_avatar_component.html.erb %>
<img src="<%= image_url %>“ alt=”<%= @user.name %>“ class=”avatar-<%= @size %>">

To render it in a view you just write:

<%= render(UserAvatarComponent.new(user: @current_user, size: :medium)) %>

This looks simple. Its power comes from what it prevents.

The Trouble with Partials

For years the standard Rails way to handle this was a partial. You might have app/views/shared/_avatar.html.erb and render it like this:

<%= render partial: 'shared/avatar', locals: { user: @current_user, size: :medium } %>

This works but it has subtle problems that grow over time.

The locals hash is just a bag of data. There is no explicit contract. You can pass anything or forget something. The partial might implicitly depend on an instance variable like @current_tenant that is set in the controller. This creates a hidden dependency. The view is no longer self contained.

Testing a partial in isolation is also awkward. You typically need a controller test or a view test to render it which is slow. You are not testing the partial. You are testing a whole rendering stack.

How Components Provide Structure

ViewComponents fix these problems by introducing discipline.

The component’s initialize method is an explicit contract. It declares exactly what data is needed. If you forget to pass a user it will raise an ArgumentError immediately. This is good. It is a bug found early.

Components are isolated. They cannot access instance variables from the controller. All data must be passed in through the initializer. This makes them predictable and reusable in any context from a view to a mailer.

Most importantly they are easy to test. Since a component is just a Ruby object you can write a fast unit test for it.

# spec/components/user_avatar_component_spec.rb
RSpec.describe UserAvatarComponent, type: :component do
  it “renders the user’s avatar with the correct url” do
    user = build(:user, name: “Test User”)
    render_inline(described_class.new(user: user, size: :medium))

    expect(page).to have_css('img[alt=“Test User”]')
    expect(page).to have_css('img[src*=“medium”]')
  end
end

This test is fast. It runs in milliseconds. It does not need a browser or a full request. You can now test your view logic with the same rigor you apply to your models.

A Practical Refactor

Think about a common pattern like a product card on an ecommerce site. It might have complex logic. It shows a discount badge if the product is on sale. It shows a “New” badge if it was added in the last week. It shows “Sold Out” if there is no inventory.

In a partial this becomes a mess of if statements.

A big ERB file with lots of conditional logic is a code smell. It is a sign that an object is missing.

By moving this to a ProductCardComponent you can create small helper methods within the component class. Methods like on_sale? or newly_added?. The template becomes clean and declarative just reading from these methods. The complex logic now lives in a Ruby object where it is easy to read and easy to test.

Start Small

You do not need to rewrite all your views. The next time you find yourself working on a complex partial try refactoring it into a ViewComponent. The next time you have to add another if statement to a view consider if that logic would be clearer inside a component.

Like service objects for models ViewComponents bring object oriented design to a part of the Rails stack that has often been neglected. They encourage you to build a library of small reusable and testable UI elements. Over time this leads to a codebase that is not just faster but clearer and more pleasant to work on.

Now take a moment to consider the most tangled part of your application’s views.

— Rishi Banerjee
September 2025