DEV Community

Honeybadger Staff for Honeybadger

Posted on • Originally published at honeybadger.io

An Introduction to the ViewComponent Gem

This article was originally written by Abiodun Olowode on the Honeybadger Developer Blog.

Inspired by React, ViewComponents are Ruby objects used to build markup for rendering views. ViewComponent is a framework for building re-usable, testable, and encapsulated view components in Rails. Typically, reusable views are created in Rails using partials and then rendered in different views as required, but with the introduction of the ViewComponent gem, partials can be swapped for view components, as they offer more advantages. Let's dive into what these advantages are.

When and Why should I use ViewComponents?

As stated earlier, view components are reusable and testable. Hence, they can be applied whenever a view is to be reused or benefits from being tested directly. Some of the benefits of view components, as stated in its documentation, include the following:

  • They are ~10x faster than partials.
  • They are Ruby objects; hence, their initialize method clearly defines what is required to render a view. This means that they are easier to understand and reuse in several other views. Furthermore, Ruby code’s quality standards can be enforced, thereby offering a reduced risk of bugs.
  • They can be unit tested as opposed to Rails’ traditional views, which require integration tests that also exercise routing and controller layers in addition to the view.

ViewComponent Implementation

ViewComponents are subclasses of ViewComponent::Base and live in app/components . Their names end in - Component, and they should be named for what they render and not what they accept. We can generate a view component either manually or via the component generator, but not without adding the gem to our gemfile and running a bundle install first.

gem "view_component", require: "view_component/engine"
Enter fullscreen mode Exit fullscreen mode

To use the generator, run the following command:

rails generate component <Component name> <arguments needed for initialization>
Enter fullscreen mode Exit fullscreen mode

For example, if we wanted to generate a component responsible for showcasing a list of in-class courses available on a learning platform, we could use this command:

rails g component Course course
Enter fullscreen mode Exit fullscreen mode

We're passing the course argument to the command because we would initialize this Ruby object with the course we expect it to display, and we named it Course because it renders a course. What a coincidence!

CourseComponent Generated

As we can see above, the component and its corresponding view are created in the app/components folder, along with a test file.

ViewComponent includes template generators for the erb, haml, and slim template engines but will default to whatever template engine is specified in config.generators.template_engine.
Nevertheless, you can indicate your preferred template engine using the following:

rails generate component Course course --template-engine <your template engine>
Enter fullscreen mode Exit fullscreen mode

Let's proceed to create our Course model and some courses to display.

rails g model Course title:string price:decimal location:string
rails db:migrate
Enter fullscreen mode Exit fullscreen mode

In our console, we can quickly create two new courses:

Course.create(title: 'The Art of Learning', price: 125.00, location: 'Denmark')
Course.create(title: 'Organizing your Time', price: 55.00, location: 'London')
Enter fullscreen mode Exit fullscreen mode

The course_component.rb file is generated with the initialize method in place, as shown below.

CourseComponent Initialized

We need to create a course controller that routes us to the list of courses.

rails g controller Courses index
Enter fullscreen mode Exit fullscreen mode

In our routes.rb file, we indicate our root route by adding the following:

root 'courses#index'
Enter fullscreen mode Exit fullscreen mode

Now that we're all set, the next step is view creation. This is done in the already generated course_component.html.erb.

<div>
  <h2><%= @course.title %></h2>
  <h4><%=  number_to_currency(@course.price, :unit => "€") %></h4>
  <h4><%= @course.location %></h4>
</div>
Enter fullscreen mode Exit fullscreen mode

In our view, we display the course title, price, and location using the @course variable, which was already defined in the initialize method of our CourseComponent. This is similar to when you create a variable in a controller method, and then it is available in a view.

Knowing how controllers work, we would be routed to the corresponding index.html.erb of our index method. Hence, this is where we render our component.

#app/views/courses/index.html.erb
<%= render(CourseComponent.new(course: Course.find(1))) %>
Enter fullscreen mode Exit fullscreen mode

As seen above, we render a new instance of our CourseComponent by initializing it with the course we intend it to render in its view. This course becomes the @course variable made available to the course_component.html.erb file.

It is also possible to render this component directly from our controller, thereby bypassing the index file view:

class CoursesController < ApplicationController
  def index
    render(CourseComponent.new(course: Course.find(1)))
  end
end
Enter fullscreen mode Exit fullscreen mode

Regardless of which method you choose, this will show up on the server:

First course displayed

Additional content can also be passed to the component in one of the following ways:

<%= render(CourseComponent.new(course: Course.find(1))) do %>
  container
<% end %>
Enter fullscreen mode Exit fullscreen mode
<%= render(CourseComponent.new(course: Course.find(1)).with_content("container")) %>
Enter fullscreen mode Exit fullscreen mode

In our view component file, we can include the content wherever we want. In this case, we'll include it as a class by editing our div to look like this:

<div class=<%= content %>>
Enter fullscreen mode Exit fullscreen mode

All of the above methods of rendering the additional content yield the image below:

Additional content being rendered

Rendering a Collection

What if we wanted to render our entire list of courses? ViewComponent provides a very straight forward way of doing this using the with_collection tag. Instead of initializing the component using .new , it is initialized using .with_collection, and the collection is passed to it as a variable, as shown below.

CourseComponent.with_collection(Course.all)
Enter fullscreen mode Exit fullscreen mode

This yields the following:

The Course collection rendered

There is also a with_collection_parameter tag available in case we wish to address the collection by a different name.

class CourseComponent < ViewComponent::Base
  with_collection_parameter :item

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

In the above case, the course parameter has been addressed as item. Hence, in the corresponding view, @course will be replaced with @item to yield the same result.

Additional parameters can also be added to the collection. These parameters will be displayed per item in the collection. Let's add a Buy Me text to each item via this method.

#app/views/courses/index.html.erb
<%= render(CourseComponent.with_collection(Course.all, notice: "Buy Me")) %>
Enter fullscreen mode Exit fullscreen mode
# app/components/course_component.rb
class CourseComponent < ViewComponent::Base
  with_collection_parameter :item
  def initialize(item:, notice:)
    @item = item
    @notice = notice
  end
end
Enter fullscreen mode Exit fullscreen mode

We add a new paragraph to the app/components/course_component.html.erb file to indicate the text for the newly added notice variable.

<p><a href='#'> <%= @notice %> </a></p>
Enter fullscreen mode Exit fullscreen mode

This yields the following:

Courses rendered with an additional notice parameter

Lastly, under collections, we have a counter variable that can be enabled to number the items in a view. It is enabled by adding _counter to the collection parameter and making it available to the views via the initialize method.

#app/components/course_component.rb
def initialize(item:, notice:, item_counter:)
  @item = item
  @notice = notice
  @counter = item_counter
end
Enter fullscreen mode Exit fullscreen mode

In our views, beside the item title, we add our counter:

<h2><%= @counter %>. <%= @item.title %></h2>
Enter fullscreen mode Exit fullscreen mode

Let's generate a third course from the console to better understand the counter phenomenon.

Course.create(title: 'Understanding Databases', price: '100', location: 'Amsterdam')
Enter fullscreen mode Exit fullscreen mode

This yields the following

Counter added to each course

Conditional Rendering

ViewComponent has a render? hook, which, when used, determines whether a view should be rendered. To implement this, we're going to give a 10% discount for courses with prices equal to or greater than 100 Euros. Let's create a component for this purpose.

rails generate component Discount item
Enter fullscreen mode Exit fullscreen mode

DiscountComponent generated

This component is already automatically initialized with the item it should display a discount for, as seen below.

DiscountComponent initialized

Hence, in the discount_component.html.erb file, we add the text we intend to display.

<p class="green"> A 10% discount is available on this course </p>
Enter fullscreen mode Exit fullscreen mode

Don't hesitate to add the class green to your css file and assign it any shade of green you prefer. Furthermore, in our discount_component.rb file, we add the render? method to determine when this component should be rendered.

def render?
  @item.price >= 100
end
Enter fullscreen mode Exit fullscreen mode

Now, we can go ahead and render the discount component within the view that renders each course.

# app/components/course_component.html.erb
<%= render(DiscountComponent.new(item: @item)) %>
Enter fullscreen mode Exit fullscreen mode

This yields the following:

DiscountComponent rendered

Isn't that awesome?

Helpers

In the traditional Rails views, we can easily plug in our helpers by calling the method name in our views, but it works differently with view components. In view components, helper methods cannot be called directly in the views but can be included in a component. We already have a courses_helper.rb file that was automatically generated when the CoursesController was created, so let's take advantage of it. First, let's create a helper method that tells us how many people have enrolled in a course so far. Let's make the value a quarter of the price :).

module CoursesHelper
  def count_enrollees(course)
    count = (course.price / 4).round()
    tag.p "#{count} enrollees so far"
  end
end
Enter fullscreen mode Exit fullscreen mode

Next, we'll create a component in which we'll call the helper. This is the component that will be rendered in our view. In it, we'll add an include statement, including the helper, and then we can call any method in the helper within this component.

# app/components/enrollee_component.rb
class EnrolleeComponent < ViewComponent::Base
include CoursesHelper

  def total_enrollees(course)
    count_enrollees(course)
  end
end
Enter fullscreen mode Exit fullscreen mode

The last step is adding the EnrolleeComponent to the view that displays our courses.

# app/components/course_component.html.erb
<%= EnrolleeComponent.new.total_enrollees(@item) %>
Enter fullscreen mode Exit fullscreen mode

Note that we are not using the render word for the EnrolleeComponent, as it doesn't have a view, and its output will be that of the helper method called. This yields the following:

Number of enrollees shown using Helpers

Helpers can be used for icons, gravitars, or whatever you might choose. ViewComponent doesn’t change the use of helpers; it just changes how we call them in our components.

The before_render method

ViewComponent offers a before_render method that can be called before a component is rendered. Let's add a star beside our discount notice. We start by adding a helper method that fetches the star; a star.png image has also been downloaded and placed in the app/assets/images folder.

#app/helpers/courses_helper.rb
def star_icon
  image_tag("/assets/star.png", width: "1%")
end
Enter fullscreen mode Exit fullscreen mode

Let's add a before_render method to our Discount component that calls this helper method.

# app/components/discount_component.rb
def before_render
  @star_icon = helpers.star_icon
end
Enter fullscreen mode Exit fullscreen mode

As we can see above, another manner of calling helpers is introduced. Helpers can also be called using helpers.method in the component.rb file. Then, in our render method for the DiscountComponent, we input our star icon, which is now made available via the @star_icon variable.

# app/components/discount_component.html.erb
<p class="green"> <%= @star_icon %> A 10% discount is available on this course </p>
Enter fullscreen mode Exit fullscreen mode

This yields the following:

Star icon shown using the before render method

We do not necessarily have to use helpers for the before_render method to work. I have used helpers to introduce another approach to calling helper methods.

Previews

Like Action Mailer, ViewComponent makes it possible to preview components. This has to first be enabled in the config/application.rb file.

config.view_component.preview_paths << "#{Rails.root}/lib/component_previews"
Enter fullscreen mode Exit fullscreen mode

Preview components are located in test/components/previews and can be used to preview a component with several different inputs passed in. We'll be previewing our DiscountComponent.

# test/components/previews/discount_component_preview.rb
class DiscountComponentPreview < ViewComponent::Preview
  def with_first_course
    render(DiscountComponent.new(item: Course.find(1)))
  end

  def with_second_course
    render(DiscountComponent.new(item: Course.find(2)))
  end
end
Enter fullscreen mode Exit fullscreen mode

Two methods have been added to preview the DiscountComponent in different scenarios. To view this, visit http://localhost:3000/rails/view_components, where we find all the preview components created and their methods. We can click on any of them to view what they look like, as shown below.

DiscountComponent previewed

As you can see in the video above, the first course renders the discount component, but nothing gets rendered for the second course. Do you know why this happened?  The render? method check happened. The first scenario is when the cost price is more than 100 euros(the 1st course), but the cost is less than 100 euros for the second course. The method names were not more descriptive to enable you figure out the cause before it is highlighted here :).

Previews can be enabled or disabled in any environment using the show_previews option, but in development and testing environments, they are enabled by default.

# config/environments/test.rb
config.view_component.show_previews = false
Enter fullscreen mode Exit fullscreen mode

JS AND CSS Inclusion

It's possible to include JavaScript and CSS alongside components, sometimes called "sidecar" assets or files. This is still an experimental feature. Hence, we won't dive into its inner workings in this article, but you can find more about this ViewComponent feature here.

Templates

The template of a view component can be defined in several ways. The simpler option is inserting the view and component in the same folder, as shown below.

A simple template for view components

As we can see here, we have every component and view in the app/components folder.

Another option is to place the view and other assets in a subdirectory with the same name as the component. Thus, in the app/components folder, we have the component.rb file, which houses the component, and then a separate course_component folder, which houses the view course_component.html.erb and every other asset related to the course_component.

ViewComponent template for asset inclusion

To generate component files in this way from the command line, the --sidecar flag is required:

rails g component Example name  --sidecar
Enter fullscreen mode Exit fullscreen mode

This enables you add your css and js files to the component folder.
ViewComponents can also render without a template file by defining a call method. An example of this is provided in the next section, where we discuss slots.

Slots

Multiple blocks of content can be passed to a single ViewComponent using slots. Similar to the has_one and has_many attributes in Rails models, slots are defined with renders_one and renders_many:

  • renders_one defines a slot that will be rendered at most once per component: renders_one :header.
  • renders_many defines a slot that can be rendered multiple times per-component: renders_many :titles.

Imagine that we want to have a page that renders a header and the titles of all the courses we have available; this can be achieved using slots. Let's create a ListComponent, which will contain a header that is only rendered once, and a TitleComponent, which renders many titles.

#app/components/list_component.rb
class ListComponent < ViewComponent::Base
  renders_one :header, "HeaderComponent"
  # `HeaderComponent` is defined within this component, so we refer to it using a string.
  renders_many :titles, TitleComponent
  # `titleComponent` will be defined in another file, so we can refer to it by class name.
  class HeaderComponent < ViewComponent::Base
    def call
      content_tag :h1, content
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

In the component above, we stated that the header is rendered once, but the titles are rendered many times, as this page would contain many titles. We have also defined the HeaderComponent within the ListComponent. Yes, this is possible with ViewComponent; a class can be defined within another class. Let's also take note of the call method discussed earlier under the Templates section and how it's being used in the HeaderComponent to render an h1 tag, thereby eliminating the need for a corresponding view(html.erb file). The corresponding HTML file for the ListComponent would contain the following;

#app/components/list_component.html.erb
<div>
  <%= header %> <!-- renders the header component -->
  <% titles.each do |title| %>
    <div>
      <%= title %> <!-- renders an individual course title -->
    </div>
  <% end %>
</div>
Enter fullscreen mode Exit fullscreen mode

In the html file, we have included the header, iterated through all the titles passed into the component, and rendered them. As you can see, we do not have to specify the name of the component to be rendered in the list view file; our slots have taken care of that. Hence, we just identify them as header and title.

The next step is to create our TitleComponent and its corresponding HTML file, as this is what will be rendered for every title passed.

# app/components/title_component.rb
class TitleComponent < ViewComponent::Base
  def initialize(title:)
    @title = title
  end
end
Enter fullscreen mode Exit fullscreen mode
# app/components/title_component.html.erb
<div>
  <h3> <%= @title %> </h3>
</div>
Enter fullscreen mode Exit fullscreen mode

Finally, in our index.html file, let's temporarily erase what we have and replace it with the ListComponent render.

#app/views/courses/index.html.erb
<%= render ListComponent.new do |c| %>
  <% c.header do %>
  <%= link_to "List of Available Courses", root_path %>
  <% end %>
  <%= c.title(title: "First title") %>
  <%= c.title(title: "Second title!") %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Individual titles rendered using Slots

Now, let's pass our courses into this view as a collection. To pass a collection into a slot, we have to pass the collection as an array of objects containing the variable needed for initialization. As we can see, every course passed in should be initialized with the title argument. We can extract the titles of all the courses in our db into an array of hashes and render it.

A hash containing the titles of all the courses

We can now replace our list of several titles with a single c.titles for a collection and pass it to our hash of titles, which we define using the variable course_titles.

# app/views/courses/index.html.erb
<% course_titles = Course.all.pluck(:title).map { |title| {title: title}} %>

<% c.titles(course_titles) %>
Enter fullscreen mode Exit fullscreen mode

This yields the following:

A collection of titles rendered using Slots

This is exactly how slots work: rendering several components within a single ViewComponent. Slots can be rendered in several other ways, and you can find more info here.

Testing

Testing view components is done by requiring "view_component/test_case" in the test file and using the render_inline test helper so that assertions are made against the rendered output. Let's begin by testing our DiscountComponent.

require "test_helper"
require "view_component/test_case"

class DiscountComponentTest < ViewComponent::TestCase
  def test_render_component
    render_inline(DiscountComponent.new(item: "my item"))
  end
end
Enter fullscreen mode Exit fullscreen mode

When we run this test using the command rails test test/components/discount_component_test.rb, we get the following error:

DiscountComponent Test Error Message

This proves to us that the test is getting to the right component but is lacking a suitable prop, as the item must be a course with a price property and not a string, as we passed. It also tells us that there is a render? method being checked before this component renders. Let's pass in the right variables now.

def test_render_component
  course = Course.create(title: 'Organizing your Time', price: 55.00, location: 'London')
  render_inline(DiscountComponent.new(item: course))
end
Enter fullscreen mode Exit fullscreen mode

This runs successfully. Let's proceed to add assertions to this test.

def test_render_component
  course = Course.create(title: 'Organizing your Time', price: 155.00, location: 'London')
  render_inline(DiscountComponent.new(item: course))
  assert_selector 'p[class="green"]'
  assert_text "10% discount"
end
Enter fullscreen mode Exit fullscreen mode

This test also passes.
Recall that there is a render condition for this component. Don’t worry, though, because ViewComponent also provides a way to test that a component is not rendered, by using refute_component_rendered. We can test this using a course with a price below 100 euros.

def test_component_not_rendered
  course = Course.create(title: 'Organizing your Time', price: 55.00, location: 'London')
  render_inline(DiscountComponent.new(item: course))
  refute_component_rendered
end
Enter fullscreen mode Exit fullscreen mode

This test also passes.

Let's write another test for our CourseComponent to test that it renders all the components nested within it.

require "test_helper"
require "view_component/test_case"

class CourseComponentTest < ViewComponent::TestCase
  def test_component_renders_all_children
    course = Course.create(title: 'Organizing your Time', price: 155.00, location: 'London')
    render_inline(CourseComponent.new(item: course, notice: 'A new test', item_counter: 1))
    assert_selector("h2", text: "Organizing your Time")
    assert_selector("h4", text: "€155.00")
    assert_selector("h4", text: "London")
    assert_text("enrollees")
    assert_text("discount")
  end
end
Enter fullscreen mode Exit fullscreen mode

This test also passes. It tests that the Enrollee and Discount components are also rendering properly.

Recall that we have a slot component, and as shown in the image below, it renders one header and many titles.

ListComponent using slots to render other Components

To test this, we pass the component a block of code containing the header and titles it should render, and then we can assert against the rendered component.

require "test_helper"
require "view_component/test_case"

class ListComponentTest < ViewComponent::TestCase
  def test_renders_slots_with_content
  render_inline(ListComponent.new) do |component|
  component.header { "A Test List" }
  component.titles [{title: 'Test title 1'}, {title: 'Test title 2'}]
  end

  assert_selector("h1", text: "A Test List")

  assert_text("Test title 1")
  assert_text("Test title 2")
  end
end
Enter fullscreen mode Exit fullscreen mode

This test also passes :).

Rspec Testing

In addition to all that has been said above about testing, if the preferred testing framework is RSpec, some additional configurations must be carried out to enable RSpec for ViewComponents.

# 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

Our DiscountComponent test can be re-written and retested using Rspec, as shown below:

require "rails_helper"

RSpec.describe DiscountComponent, type: :component do

  it "renders the component correctly" do
    course = Course.create(title: 'Organizing your Time', price: 155.00, location: 'London')
    render_inline(DiscountComponent.new(item: course))
    expect(rendered_component).to have_css "p[class='green']", text: "10% discount"
    expect(rendered_component).to have_css "img[src*='/assets/star.png']"
  end
end
Enter fullscreen mode Exit fullscreen mode

This test passes elegantly. Hell yeah, we can see our star icon.

Conclusion

Writing several view components for your Rails app not only makes your code more readable and less prone to errors from unwanted complications but also makes it possible to test your views in isolation and not only during HTTP requests. View components are easy to inculcate into an existing Rails app, and its best to start with views that are mostly reused. With everything learned so far, this should be an easy-peasy task. Nevertheless, if you would like more information on ViewComponent, do not hesitate to go through its documentation or the RubyDoc info.

Discussion (0)