If you’ve worked with Rails for any measure of time, then you know that Rails’ Views can quickly get out of hand. Between Helpers, instance variables, and inline logic, they quickly become bloated and tightly coupled to other view specific logic. Pushing these concerns into the Controller layer, or even a Presenter helps, but still lands the View in a place where it contains multiple responsibilities. If only there was a better way… Enter ViewComponents or as I like to call them, “The Missing View Layer for Rails”.
Our Example
First, let’s take a look at an example of a User listing page with pagination and actions. I find this example to be a great illustration of the power of ViewComponents while staying grounded in a real-world feature.
You don't need to be familiar with the Pagy gem. Just know that it allows us to paginate a collection of objects.
Pagy gem documentation
# users_controller.rb
class UsersController < ApplicationController
USERS_PER_PAGE = 30
def index
@filters = {
role: Role.all.select(:name)
}
@pagy, users = pagy(available_users, limit: USERS_PER_PAGE, page: params[:page])
@users = users.map { |user| UserPresenter.new(user) }
end
private
def filter_params
params.permit(filters: :role)
end
def available_users
if filter_params.present?
User.where(role: filter_params)
else
User.all
end
end
end
# user_presenter.rb
class UserPresenter < SimpleDelegator
def current_roles
roles.map(&:name).to_sentence
end
def dropdown_actions
[:edit, :destroy]
end
end
<!-- index.html.erb -->
<%= render partial: "filters", locals: { available_filters: @filters } %>
<table class="bg-gray-300 p-4">
<thead>
<tr>
<th>Name</th>
<th>Roles</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% @users.each do |user| %>
<tr>
<td><%= user.name %></td>
<td><%= user.current_roles %></td>
<td>
<%= render partial: "dropdown", locals: { resource: user, actions:
user.dropdown_actions } %>
</td>
</tr>
<% end %>
</tbody>
<tfoot>
<tr>
<td colspan="3">
<p><strong>Pagination</strong></p>
<%= pagy_nav(@pagy) %>
</td>
</tr>
</tfoot>
</table>
How many responsibilities can you find spread between the Controller, Presenter, and View for this single page? Here’s the ones I quickly identified.
- (Controller) Must understand how to set filters for the View
- (Controller) Wraps every User within a UserPresenter for additional functionality outside standard Model.
- (Controller) Must configure pagination values to prepare for View output
- (View) Needs to understand the Filter partials interface to send the correct data
- (View) Must know which table headers correspond to the User resource
- (View) Must know how to render each individual User
- (View) Must know which values to send to the actions’ dropdown, so has to understand the interface
- (View) Must utilize the proper DSL from the Pagy gem in the table footer
- (Presenter) Must know which dropdown actions to show for each User
- (Presenter) Must convert a User’s roles into a human readable string
Now imagine a new feature requirement is requested to build a similar but slightly different page for a given User’s blog posts. You’d end up copying and pasting much of the above or creating additional partials to help abstract common functionality. ViewComponents offer a cleaner, isolated way of containing related View concerns in a single location. This includes building UI libraries with consistent CSS styling, reusable components, better testability, and more.
ViewComponents make the View layer awesome
ViewComponents have a number of advantages. From proper separation of concerns, to preview-ability and testing. They really feel like a total application changer once you start using them. Co-location is a great benefit of using them as it allows for a ViewComponent object to be related to specific component rendering view code. This helps to keep your UI components focusing on single concerns. Generally this would mean you’d have a my_component.rb and my_component.html.erb file, where the my_component.rb automatically renders the my_component.html.erb file.
A framework for creating reusable, testable & encapsulated view components, built to integrate seamlessly with Ruby on Rails.
https://viewcomponent.org/
Looking at the previous example let’s inject some ViewComponent goodness.
Filters Component
The least coupled concept are the Filters. For this example, we can assume that a filter allows the user to exclude records by enum columns to keep it simple. This means you might end up with a URL like: my-site.com/users?filters[role]=admin which would only return users with the admin role. Akin to executing the following SQL:
SELECT *
FROM users
INNER JOIN user_roles ON user_roles.user_id = users.id
INNER JOIN roles ON roles.id = user_roles.role_id
WHERE roles.name = 'admin'
We have a need of an Application-specific as well as General-purpose component as it needs to know which columns to show for the filters and filters could be reusable. ViewComponent’s best practices recommend seperating responsibilities between generic and specific use cases.
General-purpose ViewComponents implement common UI patterns, such as a button, form, or modal. Application-specific ViewComponents translate a domain object into one or more general-purpose components.
https://viewcomponent.org/best_practices.html#two-types-of-viewcomponents
For Filters, we can create a component for each of the use cases allowing us to resuse the common logic for future Filter related features.
Application-specific
We’ll start by creating a UsersFilterComponent to dictate how filters are built for this specific use case.
# Application-specific
# users_filter_component.rb
class UsersFilterComponent < ViewComponent::Base
# You could also use dependency injection for more complex use cases with available roles
def filters
{
role: Role.all.select(:name)
}
end
end
Notice how the corresponding component view for UsersFilterComponent actually is using the General-purpose FilterComponent during its rendering.
<!-- users_filter_component.html.erb -->
<%= render FilterComponent.new(available_filters: filters) %>
General-purpose
Since our Application-specific component utilizes the General-purpose component, we can have the FilterComponent accept a generic available_filters argument. Some of this is psuedo code to illustrate the concept of taking a collection of filters from a Hash and rendering them iteratively in the View.
# General-purpose
# filter_component.rb
class FilterComponent < ViewComponent::Base
attr_reader :available_filters
def initialize(available_filters:)
@available_filters = available_filters
end
end
<!-- Psuedo code to build links like: ?filters[role]=admin -->
<!-- filter_component.html.erb -->
<div>
<% available_filters.keys.each do |filter_key| %>
<% available_filters[filter_key].each do |filter| %>
<%= link_to filter.name, params.merge(filter_key: filter.name) %>
<% end %>
<% end %>
</div>
We now gain some nice cleanup in our Controller and View layers:
# users_controller.rb
class UsersController < ApplicationController
USERS_PER_PAGE = 30
def index
# - REMOVED -
# @filters = {
# role: Role.all.select(:name)
# }
@pagy, users = pagy(available_users, limit: USERS_PER_PAGE, page: params[:page])
@users = users.map { |user| UserPresenter.new(user) }
end
# ... Rest of Controller
<!-- index.html.erb -->
<!-- Removed <%= render partial: "filters", locals: { available_filters: @filters } %> -->
<%= render UsersFilterComponent.new %>
<table class="bg-gray-300 p-4">
<thead>
We now have a User specific filters component and a general-purpose filters component to utilize on other pages. Next let’s dig into rendering the current User in the collection.
UserComponent
The primary section we are abstracting is the render loop from within the table body. I’ve added comments to show the section we’re looking at componentizing.
<tbody>
<% @users.each do |user| %>
<!-- UserComponent -->
<tr>
<td><%= user.name %></td>
<td><%= user.current_roles %></td>
<td><%= render partial: "dropdown", locals: { resource: user, actions: user.dropdown_actions } %></td>
</tr>
<!-- End UserComponent -->
<% end %>
</tbody>
We’ll be pulling in our Presenter logic, as it now can completely live within our new UserComponent definition. With the upcoming change below, UserPresenter can be removed and our Controller slimmed down.
Updated Controller
# users_controller.rb
class UsersController < ApplicationController
USERS_PER_PAGE = 30
def index
@pagy, @users = pagy(available_users, limit: USERS_PER_PAGE, page: params[:page])
# - REMOVED -
# @users = users.map { |user| UserPresenter.new(user) }
end
# Also deleted the UserPresenter
Updated View index.html.erb
<!-- index.html.erb -->
<tbody>
<% @users.each do |user| %>
<%= render UserComponent.new(user:) %>
<% end %>
</tbody>
New UserComponent
# New component
# user_component.rb
class UserComponent < ViewComponent::Base
attr_reader :user
def initialize(user:)
@user = user
end
# Pulled directly from Presenter
def current_roles
user.roles.map(&:name).to_sentence
end
# Pulled directly from Presenter
def dropdown_actions
[:edit, :destroy]
end
end
New UserComponent View
<!-- user_component.html.erb -->
<tr>
<td><%= user.name %></td>
<td><%= user.current_roles %></td>
<td><%= render partial: "dropdown", locals: { resource: user, actions: user.dropdown_actions } %></td>
</tr>
Now depending on how far you want to isolate the different View responsibilities there are a few additional improvements you could take:
- Create General-purpose components for Table, TableHeader, TableBody, TableCell
- Refactor the “dropdown” partial to become the DropdownComponent. Great example of a General-purpose abstraction as dropdowns are common.
- Further abstract Application-specific parts of the table for Users listing.
I’m going to skip 2, to focus on 1 and 3.
Slots and the UsersTableComponent
Following the abstraction to its next logical step, we can pull in several more View specific responsibilities into the ViewComponent layer. First we’ll start by moving the entire table tag into our new component. From here we can abstract the table headers into a ViewComponent method. Lastly, since we iterate over a collection of Users we can lean on ViewComponent’s slots.
Think of slots as a way to render multiple blocks of content, including other components.
https://viewcomponent.org/guide/slots.html
New UsersTableComponent
Since our UsersTableComponent will render an Application-specific component for User collections, we want each of the available users in the collection to be rendered by the singular UserComponent. We implement this by using the render_many slot to define a new collection called :users that renders with the UserComponent.
# users_table_component.rb
class UsersTableComponent < ViewComponent::Base
renders_many :users, UserComponent
def initialize(pagy:)
@pagy = pagy
end
def headers
["name", "roles", "actions"]
end
end
renders_many :users, UserComponent will give us a mechanism to define a collection of Users to render with the UserComponent.
New UsersTableComponent View
<!-- users_table_component.html.erb -->
<table class="bg-gray-300 p-4">
<thead>
<tr>
<% headers.each do |header| %>
<th><%= header %></th>
<% end %>
</tr>
</thead>
<tbody>
<% users.each do |user| %>
<%= user %> <!-- Will render with the UserComponent -->
<% end %>
</tbody>
<tfoot>
<tr>
<td colspan="3">
<p><strong>Pagination</strong></p>
<%= pagy_nav(@pagy) %>
</td>
</tr>
</tfoot>
</table>
Updated View index.html.erb
We’ll need to adjust our primary Controller view, to define the incoming collection of Users. Slots accomplish this with the DSL with_slot_name so for our case with_users. The with_users method call behaves exactly like the UserComponent class meaning that it can take the same arguments as the initializer. Think of with_users as equivalent to UserComponent.new.
<!-- Updated index.html.erb -->
<%= render UsersFilterComponent.new %>
<%= render UsersTableComponent.new(pagy: @pagy) do |users_table_component| %>
<% @users.each do |user| %>
<% users_table_component.with_users(user:) %>
<% end %>
<% end %>
Our cleanup is really coming along nicely now. One thing we haven’t touched on is standardizing render styles. I added a Tailwind CSS class to the <table> tag which would be nice to have all our tables utilize. Obviously, this would be most benficial once we have more than a single use case but we’ll abstract this to illustrate the point.
TableComponent
Since we render <table class="bg-gray-300 p-4"> along with a header, body, and footer portion of the table we can start to abstract these in order to ensure consistent output style. Slots are a natural way of separating a Table into discrete pieces. Note that slots automatically create predicate methods in order to check if the slot contains data. This can be seen below with the if footer? conditional check, since not all Tables will have footers.
To test whether a slot has been passed to the component, use the provided #{slot_name}? method.
https://viewcomponent.org/guide/slots.html#predicate-methods
New TableComponent
# table_component.rb
class TableComponent < ViewComponent::Base
renders_one :header
renders_one :body
renders_one :footer
end
New TableComponent View
<!-- table_component.html.erb -->
<table class="bg-gray-300 p-4">
<thead>
<tr>
<%= header %>
</tr>
</thead>
<tbody><%= body %></tbody>
<% if footer? %>
<tfoot><%= footer %></tfoot>
<% end %>
</table>
Notice how we split the generic sections of a standard table into the new TableComponent. The concerns are separated based on the thead, tbody, and optional tfoot sections. Implementors for this component can now supply what they want to render and have it appear in the appropriate section based on the slot.
We can now adjust our users_table_component to utilize the general-purpose TableComponent. This ensure that all of our table follow the same styling from our UI library.
Updated UsersTableComponent
<!-- users_table_component.html.erb -->
<%= render TableComponent.new do |table_component| %>
<% table_component.with_header do %>
<% headers.each do |header| %>
<th><%= header %></th>
<% end %>
<% end %>
<% table_component.with_body do %>
<% users.each do |user| %>
<%= user %> <!-- Will render with the UserComponent -->
<% end %>
<% end %>
<% table_component.with_footer do %>
<tr>
<td colspan="3">
<p><strong>Pagination</strong></p>
<%= pagy_nav(@pagy) %>
</td>
</tr>
<% end %>
<% end %>
Customizing CSS Classes
One thing I like to add to components is a parameter to accept CSS classes for their top level element. This keeps the component opinionated but flexible enough to allow for customization when needed.
# table_component.rb
class TableComponent < ViewComponent::Base
renders_one :header
renders_one :body
renders_one :footer
attr_reader :classes
def initialize(classes: "")
@classes = classes
end
def component_classes
"bg-gray-300 p-4 #{classes}"
end
end
<!-- table_component.html.erb -->
<table class="<%= component_classes %>">
<thead>
<tr>
<%= header %>
</tr>
</thead>
<tbody><%= body %></tbody>
<tfoot><%= footer %></tfoot>
</table>
<!-- Could be used with -->
<%= render TableComponent.new(classes: "flex bg-red-400") %>
<!-- Which would render: -->
<table class="bg-gray-300 p-4 flex bg-red-400">
<thead>
<tr>
<%= header %>
</tr>
</thead>
</table>
The class_names TagHelper comes in handy here to conditionally toggle classes based on boolean values. With it we can more easily toggle the duplicate bg Tailwind classes.
# table_component.rb
class TableComponent < ViewComponent::Base
renders_one :header
renders_one :body
renders_one :footer
attr_reader :classes
def initialize(classes: "")
@classes = classes
end
def component_classes
class_names({
"bg-gray-300" => classes.exclude?("bg-"), # Only use `bg-gray-300` if the `classes` argument does not include a Tailwind supported background color
"p-4" => true,
classes
})
end
end
There are additional concepts that can be added here such as variants which is a defined collection of styles based on a type. For example, a ButtonComponent could have a :critical variant to highlight the button in red. These are a great way to create different flavors of a component within a UI library ecosystem.
ApplicationComponent
You can even go one step further and create an ApplicationComponent that defines that each inheriting ViewComponent can pass CSS classes. I generally try to avoid this as it means that every subclass must properly call super to pass the classes argument to the ApplicationComponent. That’s additional information that could be surprising for implementors.
# application_component.rb
class ApplicationComponent < ViewComponent::Base
attr_reader :classes
def intialize(**keyword_arguments)
@classes = keyword_arguments[:classes] if keyword_arguments.key?(:classes)
super # Pass to ViewComponent::Base
end
end
# table_component.rb
class TableComponent < ApplicationComponent
renders_one :header
renders_one :body
renders_one :footer
def initialize(**keyword_arguments, another_setting: "test")
@another_setting = another_setting
super(**keyword_arguments) # Pass to ApplicationComponent
end
def component_classes
"bg-gray-300 p-4 #{keyword_arguments[:classes]}"
end
end
This is a basic enhancement but gives you an idea for creating standard ViewComponent functionality.
Pagination
Lastly, we have several pagination related concerns that are currently burried within UsersTableComponent. Pagination is a common concern in regards to listing collections so abstracting this into a ViewComponent makes a lot of sense. This can give the added benefits of allowing more customized display logic around the previous / next buttons. Additionally, we will utilize conditional rendering with then #render? method to only show Pagination when the object responds to the appropriate underlying method.
Components can implement a #render? method to be called after initialization to determine if the component should render.
https://viewcomponent.org/guide/conditional_rendering.html
class PaginationComponent < ViewComponent::Base
def initialize(pagination:)
@pagination = pagination
end
private
# Only render if the following returns true
def render?
@pagination.respond_to?(:pages)
end
end
# users_table_component.rb
class UsersTableComponent < ViewComponent::Base
renders_many :users, UserComponent
renders_one :pagination, PaginationComponent
# ... rest of file
<!-- users_table_component.html.erb -->
<%= render TableComponent.new do |table_component| %>
<% table_component.with_header do %>
<% headers.each do |header| %>
<th><%= header %></th>
<% end %>
<% end %>
<% table_component.with_body do %>
<% users.each do |user| %>
<%= user %> <!-- Will render with the UserComponent -->
<% end %>
<% end %>
<% table_component.with_footer do %>
<tr>
<td colspan="3">
<% if pagination? %>
<%= pagination %>
<% end %>
</td>
</tr>
<% end %>
<% end %>
Updated index.html.erb
<!-- Updated index.html.erb -->
<%= render UsersFilterComponent.new %>
<%= render UsersTableComponent.new(users: @users) do |users_table_component| %>
<% @users.each do |user| %>
<% users_table_component.with_users(user:) %>
<% end %>
<% users_table_component.with_pagination(pagination: @pagy) %>
<% end %>
You may have noticed above I allowed the Pagination component to accept a generic pagination argument. This was to allow for the potential for multiple pagination engines. On its own in an application this wouldn’t be very useful since most applications utilize a single mechanism to generate pagination. BUT if this were instead within a UI library you could make the rendered pagination output the expected data to the library by checking this parameter.
class PaginationComponent < ViewComponent::Base
def initialize(pagination:)
@pagination = pagination # Could be Pagy, Kaminari, something else...
Also much like rendering a top-level component, ViewComponent slots allow you to pass arguments onto their underlying component definition when using Component slots.
# users_table_component.rb
class UsersTableComponent < ViewComponent::Base
renders_many :users, UserComponent
renders_one :pagination, PaginationComponent
<!-- index.html.erb -->
<%= render UsersTableComponent.new(users: @users) do |users_table_component| %>
<% @users.each do |user| %>
<% users_table_component.with_users(user:) %>
<% end %>
<% users_table_component.with_pagination(pagination: @pagy) %>
<% end %>
So the above will render the UsersTableComponent, with the Pagination slot, where the Pagination slot is defined by the PaginationComponent, and the PaginationComponent accepts the pagination argument.
We’ve now built upon several of the previous concepts leveraging: Generic slots, Component slots, Slot predicate methods, collection rendering, and conditionally rendered components. Two things we’ve yet to cover are Previewing and Testing. We’ll finish out the article with a brief summary of each.
Previewing
Like Rails Mailer previews, ViewComponents have preview functionality that works in much the same way. By creating a Preview file and visiting the appropriate route, you’ll have a playground to test out your component isolated from your application.
# test/components/table_component_preview.rb
class TableComponentPreview < ViewComponent::Preview
# Route: /rails/view_components/table_component/default
def default
render TableComponent.new do |table_component|
table_component.with_header do
tag.th do
"My Header Content"
end
end
table_component.with_body do
tag.td do
"My Body Content"
end
end
end
end
# Route: /rails/view_components/table_component/with_footer
def with_footer
render TableComponent.new do |table_component|
table_component.with_header do
tag.th do
"My Header Content"
end
end
table_component.with_body do
tag.td do
"My Body Content"
end
end
table_component.with_footer do
tag.td do
"My Footer Content"
end
end
end
end
end
<!-- Default Preview -->
<table class="bg-gray-300 p-4">
<thead>
<tr>
<th>My Header Content</th>
</tr>
</thead>
<tbody>
<tr>
<td>My Body Content</td>
</tr>
</tbody>
</table>
<!-- With Footer Preview -->
<table class="bg-gray-300 p-4">
<thead>
<tr>
<th>My Header Content</th>
</tr>
</thead>
<tbody>
<tr>
<td>My Body Content</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>My Footer Content</td>
</tr>
</tfoot>
</table>
I highly recommend Lookbook as it provides additional functionality for changing the output of a component based on parameters.
Lookbook is a UI development environment for Ruby on Rails applications.
https://lookbook.build/
Testing
Testing ViewComponents is nearly as easy as testing a plain ole Ruby object. Generally there are two types of tests you’ll want to write: Unit and System. Our UserComponent defines several methods to be utilized within its corresponding View file. We can test these quite easily with either RSpec or Minitest.
UserComponent Unit Test
For our Unit test, we’ll only focus on the defined methods that are part of the Component. Think of this as testing a Presenter’s methods as it is a similiar context.
# spec/components/user_component_spec.rb
describe UserComponent, type: :component do
let(:user) { create(:user)}
let(:component) { described_class.new(user:) }
describe "#dropdown_actions" do
it "returns the correct actions" do
expect(component.dropdown_actions).to eq([:edit, :destroy])
end
end
describe "#current_roles" do
it "returns the correct roles" do
create(:role, name: "Admin", user:)
create(:role, name: "User", user:)
expect(component.current_roles).to eq("Admin, User")
end
end
end
UserComponent System Test
A system test, tests an end-to-end flow based on what the real world experience may look like. This is a great way to ensure that the component’s content, styles, and sometimes elements are rendered as expected.
# spec/components/user_component_spec.rb
describe UserComponent, type: :system do
let(:user) { build_stubbed(:user, name: "Nic Cage") }
it "renders the User within a Table row" do
render_inline(UserComponent.new(user:))
expect(page).to have_content(user.name)
expect(page).to have_selector("tr")
end
end
The above is a basic system test that uses Capybara under-the-hood to render and test the UserComponent’s html output. Anything that can be done with Capybara is fair to utilize here.
Conclusion
Now that we’ve abstracted several of the View concerns elsewhere, our top level interface for the Controller is much cleaner. It allows the Controller better focus on business logic giving us a natural place to introduce concepts like Service Objects, Query Objects, and Form Objects.
Here’s the end-result for our Controller & View interface:
# users_controller.rb
class UsersController < ApplicationController
USERS_PER_PAGE = 30
def index
@pagy, @users = pagy(available_users, limit: USERS_PER_PAGE, page: params[:page])
end
private
def filter_params
params.permit(filters: :role)
end
def available_users
if filter_params.present?
User.where(role: filter_params)
else
User.all
end
end
end
<!-- index.html.erb -->
<%= render UsersFilterComponent.new %>
<%= render UsersTableComponent.new(users: @users) do |users_table_component| %>
<% @users.each do |user| %>
<% users_table_component.with_users(user:) %>
<% end %>
<% users_table_component.with_pagination(pagination: @pagy) %>
<% end %>
The next engineer now doesn’t need to worry about the various components of this View since they are isolated. This reduces the amount of mental overhead allowing them to focus on the feature requirements. We have dedicated locations for our Component logic, rendering, previewing, and testing.
I’ve only gone surface level with ViewComponents as there much more depth and nuance to them. If you’ve had success (or failures) with utilizing ViewComponents as your View layer, I’d love to hear about it in the comments below. Thanks for reading!
Setting up Mac for the Linux user
Custom Naming for Database Tables, Columns, and Associations in Prisma ORM
Join the conversation