A component-based approach to writing views in Ruby on Rails applications using View Components

Ramilya Nigmatullina
Flatstack Thoughts
Published in
7 min readNov 26, 2020

--

Today I would like to talk about a component-based approach to writing views on Ruby on Rails applications using View Components.

What are View Components?

View Components is a new approach to programming views invented by GitHub. View Components are Ruby classes that can return HTML. This is a new vision of the presenter pattern. View Components is provided as a gem and it has detailed documentation. While writing this article the gem is being developed. But nowadays many people use this gem in production, including GitHub itself.

Let’s turn to the documentation itself for a better understanding of why View Components are needed, and when they should be used.

The inventors of this gem say that using View Components is mostly useful in the following cases:

  1. The code is reused repeatedly in many places
  2. The code needs to be tested thoroughly
  3. The code from the view can be moved into a separate file (partial)

Unlike traditional views, it’s easier to test View Components using unit tests. Here’s what is said about it in the gem documentation:

Unlike traditional Rails views, ViewComponents can be unit-tested. In the GitHub codebase, component unit tests take around 25 milliseconds each, compared to about six seconds for controller tests. Rails views are typically tested with slow integration tests that also exercise the routing and controller layers in addition to the view. This cost often discourages thorough test coverage.

Another advantage is their speed. I think everyone uses partials when rendering views. They are convenient because they help you avoid code duplication. Also, you can put logically related code into a partial and call the render of this partial in the right place. This approach makes views clearer and easier to understand. But partials have a big drawback — they are slow while working. When the render command is called, the rails have to search for the necessary path to the partial. Therefore, no need to use partials often, only if they are really needed.

Partials can be replaced with View Components. The gem’s inventors claim that View Components are 10 times faster. According to other sources, View Components work 3 times faster. Of course, these numbers may differ, since it depends on what exactly was rendered in partials, how large and complex the code was there initially.

And the last thing — View Components have a preview like mailers. This can be very handy in development.

Let’s take a base project of Flatstack — Rails Base, turn it into a small blog and rewrite some views using view components to demonstrate how View Components work.

First of all, we need to add the ability to create, view, edit and delete articles. I won’t tell about it in details as all the necessary code is contained in this commit: https://github.com/RamilyaNigmatullina/view-components-demo/commit/c3bf6761990e703342685cc9f66c3ea450838f1e

This article contains four parts:

  1. Replacing partials for navigation
  2. Replacing the partial for rendering the collection of articles
  3. Writing tests using RSpec
  4. Writing previews

Replacing partials for navigation

Let’s create a repository first.

Then add the ability to create, view, edit and delete articles. I won’t tell about it in details.

After that we add the view_component gem, run bundle install and restart the server:

# Gemfile
gem "view_component", require: "view_component/engine"

Next, let’s add View Components. The easiest way is to start replacing partials. Let’s take the navigation bar, it consists of three files.

#app/views/application/_navigation.html.slim
.title-bar data-hide-for="medium" data-responsive-toggle="navigation_menu"
button.menu-icon data-toggle="" type="button"
title-bar-title Menu
nav.top-bar#navigation_menu
= render "navigation_main"
= render "navigation_user"

# app/views/application/_navigation_main.html.slim
.top-bar-left
ul.menu
li
= link_to "ViewComponent Demo", root_path
- if user_signed_in?
= active_link_to "New Article", new_article_path, active: :exclusive, wrap_tag: :li

# app/views/application/_navigation_user.html.slim
ul.top-bar-right.menu.dropdown(data-dropdown-menu)
- if user_signed_in?
li
a = current_user.full_name
ul.menu
li = link_to "Edit profile", edit_user_registration_path
li = link_to "Sign out", destroy_user_session_path
- else
= active_link_to "Sign in", new_user_session_path, active: :exclusive, wrap_tag: :li
= active_link_to "Sign up", new_user_registration_path, active: :exclusive, wrap_tag: :li

All components must be stored in the app/components folder. There are different ways to structure files inside the app/components folder. You can read more detailed about all the methods here. We’ll use a way called the component file inside sidecar directory. The main idea of this way is that you need to create a directory with the name of the element — navigation_bar in our case. You need to create the following files in this directory:

  1. component.rb — Ruby code of this component
  2. component.html.slim — HTML code of this component
  3. component.js — Javascript code of this component (you don’t need to create this file if the Javascript isn’t needed for this component)

Let’s create an app/components/shared/navigation_bar directory and component.rb and component.html.slim inside this directory. You need to pass the current_user to the component since the content of the navigation bar depends on whether the user is authenticated or not. As a result of these operations component.rb looks like this:

# app/components/shared/navigation_bar/component.rb
module Shared
module NavigationBar
class Component < ViewComponent::Base
def initialize(current_user:)
@current_user = current_user
end
private

attr_reader :current_user
end
end
end

Transfer all code from the partial to the to component.html.slim except renders of naviagtion_menu and navigation_user:

# app/components/shared/navigation_bar/component.html.slim
.title-bar data-hide-for="medium" data-responsive-toggle="navigation_menu"
button.menu-icon data-toggle="" type="button"
title-bar-title Menu
nav.top-bar#navigation_menu

Change render of the navigation partial into render of the component:

# app/views/layouts/application.html.slim
= render Shared::NavigationBar::Component.new(current_user: current_user)

Open the website http://lvh.me:5000/ and check that the code works, the navigation bar is rendered. Next, you need to transfer navigation_menu and navigation_user partials. Let’s change slightly the structure of the navigation bar, we will create the GuestUserLinks and AuthenticatedUserLinks components instead of these partials. Let’s start with GuestUserLinks. If the user is not authenticated, he must see Sign in and Sign up links.

# app/components/shared/navigation_bar/guest_user_links/component.rb
module Shared
module NavigationBar
module GuestUserLinks
class Component < Shared::NavigationBar::Component
end
end
end
end
# app/components/shared/navigation_bar/guest_user_links/component.html.slim
- unless current_user
.top-bar-left
ul.menu
li = link_to "ViewComponent Demo", root_path
ul.top-bar-right.menu.dropdown(data-dropdown-menu)
= active_link_to "Sign in", new_user_session_path, active: :exclusive, wrap_tag: :li
= active_link_to "Sign up", new_user_registration_path, active: :exclusive, wrap_tag: :li

And let’s render this component:

# app/components/shared/navigation_bar/component.html.slim
= render Shared::NavigationBar::GuestUserLinks::Component.new(current_user: current_user)

Open the website http://lvh.me:5000/ and check that the code works, the navigation bar is rendered, but the component can be improved. You can define a special render? in component instead of writing a condition under which you want to render the component at the beginning of the HTML:

# app/components/shared/navigation_bar/guest_user_links/component.rb
def render?
!current_user
end

Let’s create the AuthenticatedUserLinks component in the same way.

# app/components/shared/navigation_bar/authenticated_user_info/component.rb
module Shared
module NavigationBar
module AuthenticatedUserLinks
class Component < Shared::NavigationBar::Component
def render?
current_user
end
end
end
end
end
# app/components/shared/navigation_bar/authenticated_user_links/component.html.slim
.top-bar-left
ul.menu
li = link_to "ViewComponent Demo", root_path
li = active_link_to "New Article", new_article_path, active: :exclusive, wrap_tag: :li
ul.top-bar-right.menu.dropdown(data-dropdown-menu)
li
a = current_user.full_name
ul.menu
li = link_to "Edit profile", edit_user_registration_path
li = link_to "Sign out", destroy_user_session_path
# app/components/shared/navigation_bar/component.html.slim
= render Shared::NavigationBar::AuthenticatedUserLinks::Component.new(current_user: current_user)

It’s interesting that component methods can also return HTML code. It is convenient to move the code into methods if the code contains some complex logic. This makes the presentation look clearer and easier to understand. For example, notice that the link to the home page of the site is duplicated in two components. We can move it to the root_link method in the NavigationBar component and call this method in the GuestUserLinks and AuthenticatedUserLinks components:

# app/components/shared/navigation_bar/component.rb
def root_link
link_to "ViewComponent Demo", root_path
end
# app/components/shared/navigation_bar/authenticated_user_links/component.html.slim
li = root_link
# app/components/shared/navigation_bar/guest_user_links/component.html.slim
li = root_link

Thus, we have moved all the navigation into view components. All the changes can be viewed in this commit: https://github.com/RamilyaNigmatullina/view-components-demo/commit/d2c742ed98a4751426ccafdbc54000bb6717ee91

Replacing the partial for rendering the collection of articles

Now let’s move the partial for rendering the article list to the View Components.

# app/components/articles/list_item/component.rb
module Articles
module ListItem
class Component < ViewComponent::Base
with_collection_parameter :article
def initialize(article:)
@article = article
end
private attr_reader :article
end
end
end
# app/components/articles/list_item/component.html.slim
.article
h3 = link_to article.title, article_path(article)
.article__body
= truncate(article.body, length: 750) { link_to " read more", article_path(article) }
.article__info
div
= article.user.full_name
div
= article.humanized_created_at

Note that the with_collection_parameter method was added to the component. It is needed so that the component could understand that each relation object is called an article. In order to render the collection with an article, let’s call the following method:

# app/views/articles/index.html.slim
= render Articles::ListItem::Component.with_collection(articles)

All the changes can be viewed in this commit https://github.com/RamilyaNigmatullina/view-components-demo/commit/dad9a0d9c4089de5c960351b32baa3e96f4c1927

Writing tests using RSpec

Now let’s write cover all the components with tests.

# spec/components/shared/navigation_bar/component_spec.rb
require "rails_helper"
describe Shared::NavigationBar::Component, type: :view do
subject(:component) { described_class.new(current_user: current_user) }
let(:current_user) { nil } it "displays guest user's navigation bar" do
render(component)
expect(rendered).to have_link("Sign in", href: new_user_session_path)
expect(rendered).to have_link("Sign up", href: new_user_registration_path)
end
context "with current user" do
let(:current_user) { create :user, full_name: "John Smith" }
it "displays authenticated user's navigation bar" do
render(component)
expect(rendered).to have_content("John Smith")
expect(rendered).to have_link("Edit profile", href: edit_user_registration_path)
expect(rendered).to have_link("New Article", href: new_article_path)
expect(rendered).to have_link("Sign out", href: destroy_user_session_path)
end
end
end
# spec/components/articles/list_item/component_spec.rb
require "rails_helper"
describe Articles::ListItem::Component, type: :view do
subject(:component) { described_class.with_collection(articles) }
let(:articles) { ArticleDecorator.decorate_collection(Article.all) }
let!(:first_article) { create :article, title: "Lorem ipsum" }
let!(:second_article) { create :article, title: "Maxime dolorem quo animi" }
it "displays articles" do
render(component)
expect(rendered).to have_link("Lorem ipsum", href: article_path(first_article))
expect(rendered).to have_link("Maxime dolorem quo animi", href: article_path(second_article))
end
end

Writing previews

And finally, let’s write previews for all View Components. We will create a special layout first in which all the components will be displayed:

# app/views/layouts/component_preview.html.slim
doctype html
html class="no-js" lang="en"
head
meta charset="utf-8"
meta name="viewport" content="width=device-width, initial-scale=1.0"
meta name="robots" content="NOODP,NOYDIR"
= display_meta_tags site: "RailsBase", keywords: %w[title site]= csrf_meta_tags= stylesheet_link_tag :applicationbody= yield= javascript_pack_tag :application
= javascript_include_tag :application

Add additional settings to config/application.rb:

  1. Set the layout for the preview
  2. Set the path to the directory in which the previews will be located
  3. Change the default route for the preview (/rails/view_components) into a new one, as it is shorter
# config/application.rb
config.view_component.default_preview_layout = "component_preview"
config.view_component.preview_paths << Rails.root.join("spec", "components", "previews")
config.view_component.preview_route = "/previews"
# spec/components/previews/shared/navigation_bar/component_preview.rb
module Shared
module NavigationBar
class ComponentPreview < ViewComponent::Preview
def guest_user
render Shared::NavigationBar::Component.new(current_user: nil)
end
def authenticated_user
render Shared::NavigationBar::Component.new(current_user: User.first)
end
end
end
end
# spec/components/previews/articles/list_item/component_preview.rb
module Articles
module ListItem
class ComponentPreview < ViewComponent::Preview
def list
articles = ArticleDecorator.decorate_collection(Article.limit(5))
render Articles::ListItem::Component.with_collection(articles)
end
end
end
end

Previews are available from the links below:

All changes can be viewed in this commit https://github.com/RamilyaNigmatullina/view-components-demo/commit/2b875c67c6d9afc6635ddf887eea9ad477c7231b

Thus, we have covered the use of View Components. You can find more information in the official documentation.

Thanks for your attention!

--

--