DEV Community

Cover image for Exploring ViewComponent
Ana Nunes da Silva
Ana Nunes da Silva

Posted on • Edited on • Originally published at ananunesdasilva.com

Exploring ViewComponent

What is ViewComponent

ViewComponent is an open-source framework built by Github to solve some of the Rails views pattern issues that emerged from building a large-scale application.

Views pattern issues?

Rails views are not encapsulated, they exist in a single execution context - meaning they can share state. This lack of isolation makes them hard to organize, reuse, scale, and also test.

We have all been there. Between views, partials, presenters, and helpers it might become hard to make sense of all the view logic, and duplication quickly starts to emerge. Maintaining and making changes in the frontend can be a cumbersome task.

Testing these views is also expensive. You will have to run slow integration or system tests that go through the router and controller layers, in addition to the view layer.

This is what ViewComponent decided to tackle. Github understood that there was a missing abstraction in the Rails view layer.

A good metaphor is written on the official documention:

"ViewComponent is to UI what ActiveRecord is to SQL"

Enter encapsulation

Inspired by React, the main idea is to make ruby objects render views.

In practice, a ViewComponent will be a combination of a ruby file and a template file.

This will force the single responsibility principle to be applied to the views. No more scattering view-related logic across models, controllers, and helpers. The view logic will now be the responsibility of a single class, encapsulating it in a proper object-oriented fashion. And that logic will be accessible only to a matching template.

At the end of the day, this encapsulation is what will make the view components reusable and testable.

Let's see it in practice by refactoring views built the rails way.

Refactoring rails views into components

Installation

In Gemfile, add:

gem "view_component"

Once that is installed you will have access to a new component generator.

This generator accepts a name for the ViewComponent and the arguments.

Unless you pass other options, the generator will create a test file (defaults to minitest), a ruby file, and a template file (defaults to html):

bin/rails generate component <name of the component> <params to be used by the component>
      invoke  test_unit
      create  test/components/example_component_test.rb
      create  app/components/example_component.rb
      create  app/components/example_component.html.erb
Enter fullscreen mode Exit fullscreen mode

You can see the full generator documentation here.

Reusable components

As a rule of thumb, a template that has the potential to be reused is a good candidate for a ViewComponent.

Let's start with a simple example - page titles.

I have a heading that will be the same on all pages, only the content is dynamic, like this:

<h1 class="mb-4 text-2xl font-bold">Posts</h1>
Enter fullscreen mode Exit fullscreen mode

Let's create a component:

rails g component Heading --test-framework rspec
Enter fullscreen mode Exit fullscreen mode

Note that I am not passing any arguments, since only the content will be dynamic. I'm also using the --test-framework flag to create a rspec test file instead of minitest.

So my component class will look like this:

# components/heading_component.rb
class HeadingComponent < ViewComponent::Base
end
Enter fullscreen mode Exit fullscreen mode

And here's the component's html:

<%#components/heading_component.html.erb%>
<h1 class="mb-4 text-2xl font-bold"><%= content %></h1>
Enter fullscreen mode Exit fullscreen mode

Now I can call this component in all the application's pages. For example:

<%#views/posts.html.erb%>
<%= render(HeadingComponent.new.with_content("Posts")) %>
Enter fullscreen mode Exit fullscreen mode

Passing arguments

Ok, so what if I also have buttons on different pages but they might be of different types (e.g. primary, danger,...). If I want to make a reusable component, I will have to pass the type as an argument:

rails g component Button type --test-framework rspec
Enter fullscreen mode Exit fullscreen mode

Now the component class will be initialized with the type.

# components/button_component.rb
class ButtonComponent < ViewComponent::Base
  def initialize(type: type)
    @type = type
  end
end
Enter fullscreen mode Exit fullscreen mode

And this instance variable can now be used in the component's view file.

<%#components/button_component.html.erb%>
<button type="button" data-view-component="true" class="btn-<%= @type %> btn">
  Click me
</button>
Enter fullscreen mode Exit fullscreen mode

Now we just need to render the component in the rails views:

<%#views/books/show.html.erb%>
<%= render(ButtonComponent.new(type: "primary").with_content("Click me")) %>
Enter fullscreen mode Exit fullscreen mode

Collections

When we are listing something, for example in an index page:

<%#views/books/index.html.erb%>
<% @books.each do |book| %>
  <h2>book.title</h2>
  <p>book.description</p>
<% end %>
Enter fullscreen mode Exit fullscreen mode

We could start the refactoring by creating a component for a book:

rails g component Books book --test-framework rspec
Enter fullscreen mode Exit fullscreen mode
#components/book_component.rb
class BookComponent < ViewComponent::Base
  def initialize(book:)
    @book = book
  end
end
Enter fullscreen mode Exit fullscreen mode
<%#components/book_component_html.erb%>
<h2>@book.title</h2>
<p>@book.description</p>
Enter fullscreen mode Exit fullscreen mode
<%#views/books/index.html.erb%>
<% @books.each do |book| %>
  <%= render(BookComponent.new(book: book)) %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

But what if we could also abstract the iteration by somehow telling the component to render itself for each element of the collection?

Gladly, ViewComponent has two handy methods for this: .with_collection and .with_collection_parameter.

So let's adjust the component class and reference the parameter that should be used to create a collection:

#components/book_component.erb
class BookComponent < ViewComponent::Base
  with_collection_parameter :book

  def initialize(book:)
    @book = book
  end
end
Enter fullscreen mode Exit fullscreen mode

No need to adjust the component html. The only thing we need is to go to the rails view, remove the iteration and render theBookComponent with the .with_collection method, passing the collection that we want to render.

<%# views/books/index.html.erb%>
<%= render(BookComponent.with_collection(@books)) %>
Enter fullscreen mode Exit fullscreen mode

The content block

In some of the previous examples, I have been passing text as content to the component. That's because, as mentioned in the documentation:

"Content passed to a ViewComponent as a block is captured and assigned to the content accessor."

So instead of passing text, we can actually pass a component to another.

Let's start from the end result now:

<%#views/products/index.html.erb%>
<%= render(GridListComponent.new) do %>
  <%= render(ProductComponent.with_collection(@products)) %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Just by reading the code, we can already infer that we will have some sort of list that will render each item of the product collection.

Let's look at the individual components now, starting from the outside - the grid list component first:

<%#components/grid_list_component.html.erb%>
<div class="grid grid-cols-2 gap-4">
  <ul class="col-span-1 grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 sm:gap-x-6 lg:grid-cols-4 xl:gap-x-8">
    <li>
      <%= content %>
    </li>
  </ul>
</div>
Enter fullscreen mode Exit fullscreen mode

Note the content accessor on the <li> element. This accessor will render whatever it is passed to the block. So if we pass the product component, which is:

<%#components/product_component.html.erb%>
<a href="<%= product_path(@product) %>">
  <img src="..." alt="..." class="...">
</a>
Enter fullscreen mode Exit fullscreen mode

In the end, this is what the rendered html will look like:

<div class="grid grid-cols-2 gap-4">
  <ul class="col-span-1 grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 sm:gap-x-6 lg:grid-cols-4 xl:gap-x-8">
    <li>
      <a href="/products/1">
        <img src="..." alt="..." class="...">
      </a>
    </li>
  </ul>
</div>
Enter fullscreen mode Exit fullscreen mode

Slots

Another way to compose components is through slots. As stated in the documentation,

"In addition to the content accessor, ViewComponents can accept content through slots. Think of slots as a way to render multiple blocks of content, including other components."

You will have to mention which slots the component accepts, using either renders_one or renders_many.

If you want to use an existing component to render the slot, you will have to pass it as a second argument.

It is also possible to define a new component as a subclass of the StoreComponent class. For that, you will need to pass the name of the component as a string on the second argument/

Let's look at an example with a Store component that will render a header and books:

# components/store_component.rb
class StoreComponent < ViewComponent::Base
  renders_one :header, "HeaderComponent"
  renders_many :books, BookComponent

  class HeaderComponent < ViewComponent::Base
    attr_reader :classes

    def initialize(classes:)
      @classes = classes
    end

    def call
      content_tag :h1, content, { class: classes }
    end
   end
end
Enter fullscreen mode Exit fullscreen mode
<%# store_component.html.erb %>
<%= header %>

<% books.each do |book| %>
  <%= book %>
<% end %>
Enter fullscreen mode Exit fullscreen mode
<%# index.html.erb %>
<%= render StoreComponent.new do |c| %>
  <% c.with_header(classes: "") do %>
    <%= link_to "My Store", root_path %>
  <% end %>

  <% c.with_book(title: "A book") do %>
     Nice Book
  <% end %>

  <% c.with_book(title: "Another book") do %>
    Read books
  <% end %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Testing

Since components are isolated, we can skip the heavy system tests for now and just focus on simple and fast unit tests.

I'm going to use Rspec, but you can read the documentation for some minitest examples.

To use Rspec, you will need to add some ViewComponent's helpers and capybara matchers to spec/rails_helper.rb.

Make sure you have both rspec and capybara installed:

group :development, :test do
  gem 'capybara'
  gem "rspec-rails", "~> 5.0.0"
end
Enter fullscreen mode Exit fullscreen mode

Add ViewComponent and Capybara configs:

# spec/rails_helper.rb
require "view_component/test_helpers"
require "capybara/rspec"

RSpec.configure do |config|
  config.include ViewComponent::TestHelpers, type: :component
  config.include Capybara::RSpecMatchers, type: :component
end
Enter fullscreen mode Exit fullscreen mode

And using the BookComponent from earlier we can test that it prints the book titles, both individually and for a collection:

require "rails_helper"

RSpec.describe BookComponent, type: :component do
  book_1 = Book.create(title: 'Well Grounded Rubyist')
  book_2 = Book.create(title: '99 Bottles')

  it "renders a book" do
    render_inline(described_class.new(listing: book_1))

    expect(page).to have_text('Well Grounded Rubyist')
  end

  it "renders a listing collection" do
    render_inline(described_class.with_collection(Listing.all))

    expect(page).to have_text('Well Grounded Rubyist')
    expect(page).to have_text('99 Bottles')
  end
end
Enter fullscreen mode Exit fullscreen mode

Going further

There is still a lot I want to explore using ViewComponent. For now, I feel that the beginning is fairly straightforward but I can see that it is not always easy to figure out what the right abstraction should be and how to best compose related components. This is not something inherent to ViewComponent per se though. It's the principles behind encapsulation and reusability that forces you to think a little beforehand so we can pick up the fruits later!

As always, the more the community explores and shares its findings, the more we can learn from each other, see the good/bad patterns and even contribute to the project.

I found the official documentation and videos really helpful and there are topics that I would like to cover in future explorations:

  • The content accessor Vs. slots
  • How to use ViewComponents with stimulus
  • Previewing components in isolation
  • Building a components library with ViewComponents and Storybook

There are also great ViewComponent based libraries that we can use or take ideas from:

References:

viewcomponent.org
ViewComponents in the Real World - Joel Hawksley
Encapsulating ruby on rails view

Top comments (0)