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"
To use the generator, run the following command:
rails generate component <Component name> <arguments needed for initialization>
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
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!
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>
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
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')
The course_component.rb
file is generated with the initialize method in place, as shown below.
We need to create a course controller that routes us to the list of courses.
rails g controller Courses index
In our routes.rb
file, we indicate our root route by adding the following:
root 'courses#index'
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>
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))) %>
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
Regardless of which method you choose, this will show up on the server:
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 %>
<%= render(CourseComponent.new(course: Course.find(1)).with_content("container")) %>
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 %>>
All of the above methods of rendering the additional content yield the image below:
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)
This yields the following:
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
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")) %>
# app/components/course_component.rb
class CourseComponent < ViewComponent::Base
with_collection_parameter :item
def initialize(item:, notice:)
@item = item
@notice = notice
end
end
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>
This yields the following:
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
In our views, beside the item title, we add our counter:
<h2><%= @counter %>. <%= @item.title %></h2>
Let's generate a third course from the console to better understand the counter phenomenon.
Course.create(title: 'Understanding Databases', price: '100', location: 'Amsterdam')
This yields the following
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
This component is already automatically initialized with the item it should display a discount for, as seen below.
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>
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
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)) %>
This yields the following:
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
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
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) %>
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:
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
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
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>
This yields the following:
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"
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
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.
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
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.
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.
To generate component files in this way from the command line, the --sidecar
flag is required:
rails g component Example name --sidecar
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
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>
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
# app/components/title_component.html.erb
<div>
<h3> <%= @title %> </h3>
</div>
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 %>
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.
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) %>
This yields the following:
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
When we run this test using the command rails test test/components/discount_component_test.rb
, we get the following error:
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
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
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
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
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.
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
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
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
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.
Top comments (0)