July 31, 2024
•Last updated July 31, 2024
ViewComponent Crash Course with Ruby on Rails
ViewComponent is a powerful gem that allows you to create reusable, testable, and encapsulated view components in Ruby on Rails applications.
ViewComponent was developed by the GitHub team and has since been adopted by many developers looking for more structure than the default partials and helpers provided by Ruby on Rails. This crash course will review ViewComponent's use case and explore how it might fit your next Rails app.
Installation
rails new view_component_demo -c tailwind -j bun
To begin using ViewComponent, add it to your Rails application's Gemfile:
gem "view_component"
gem "devise"
gem "name_of_person"
Then, run bundle install
to install the gem.
We must expand the config's content
array to make Tailwind work effectively in our components.
// tailwind.config.js
module.exports = {
content: [
"./app/components/**/*.rb", // Add this line
"./app/components/**/*.html.erb", // Add this line
"./app/views/**/*.html.erb",
"./app/helpers/**/*.rb",
"./app/assets/stylesheets/**/*.css",
"./app/javascript/**/*.js",
],
}
Scaffold the app
Install Devise to get started. We’ll leverage the user model to generate a realistic setup for the demo app.
rails g devise:install
rails g devise User
Be sure to follow all the configuration steps that the installer outputs. I’ve done this many times in other tutorials, so I’ll leave that process out of this one.
I added the name_of_person gem to this app. Follow the docs to install it. We’ll need two new columns for the user model. For that, I’ll generate a new migration and migrate the database. This gem makes handling names super simple with some Ruby code.
rails g migration add_name_to_users first_name:string last_name:string
rails db:migrate
rails g devise:views
Next, update your application_controller.rb
file to reflect the following:
class ApplicationController < ActionController::Base
before_action :configure_permitted_parameters, if: :devise_controller?
protected
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: [:name])
devise_parameter_sanitizer.permit(:account_update, keys:[:name])
end
end
Add a new name
field to extend the signup forms.
<!-- app/views/devise/registrations/edit.html.erb-->
<div class="mb-6">
<%= f.label :name %><br />
<%= f.text_field :name %>
</div>
<!-- app/views/devise/registrations/new.html.erb-->
<div class="mb-6">
<%= f.label :name %><br />
<%= f.text_field :name %>
</div>
Finally, I updated my layout to account for Devise and some general styles.
<!-- app/views/layouts/application.html.erb-->
<!DOCTYPE html>
<html>
<head>
<title>ViewComponentDemo</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>
</head>
<body>
<div class="container mx-auto px-4 py-12">
<nav>
<%= link_to "Home", root_path, class: "text-lg font-medium underline mr-2" %>
<% if user_signed_in? %>
<%= link_to current_user.name, edit_user_registration_path, class: "text-lg font-medium underline" %>
<% else %>
<%= link_to "Sign in", new_user_session_path, class: "text-lg font-medium underline" %>
<%= link_to "Sign up", new_user_registration_path, class: "text-lg font-medium underline" %>
<% end %>
</nav>
<div class="mt-16">
<%= yield %>
</div>
</div>
</body>
</html>
Modeling the app
We already have a User
model, but our Post model is now the focus.
Scaffold a Post
model and set your root path to posts#index
inside config/routes.rb
rails g scaffold Post title:string content:text user:references
Creating Your First ViewComponent
ViewComponent follows a convention-based approach to creating and organizing components. Here are some fundamental conventions to keep in mind:
- Components are subclasses of
ViewComponent::Base.
- Components are typically stored in the
app/components
directory - Component names end with
Component
- Module names for components are commonly plural (e.g.,
Users::AvatarComponent
)
To create your first component, you can use the built-in generator:
bin/rails generate component Button label href
This command will generate the following files:
app/components/button_component.rb
app/components/button_component.html.erb
test/components/button_component_test.rb
Implementing the Component
Let's take a look at a basic implementation of a component:
I like to use inheritance and add an application_component.rb
file to the mix. All components can then adopt some common patterns as necessary. For instance, view helpers can happen globally on the ApplicationComponent
class, inheriting from the ViewComponent::Base
component.
# app/components/application_component.rb
# frozen_string_literal: true
class ApplicationComponent < ViewComponent::Base
include Rails.application.routes.url_helpers
include Devise::Controllers::Helpers
def helpers
ActionController::Base.helpers
end
end
Now, our button component can inherit from the application component, similar to how Ruby and Rails already works.
# app/components/button_component.rb
class ButtonComponent < ApplicationComponent
def initialize(label: nil, href:, :theme)
@label = label
@href = href
@theme = theme
end
def button_theme
case @theme
when :primary
"btn btn-primary"
when :secondary
"btn btn-secondary"
when :white
"btn btn-white"
else
"btn btn-primary"
end
end
end
In this example, I've created a ButtonComponent
that accepts a label,
href,
and theme
parameter. The label can be nil, which we’ll address in the template with a conditional statement. I’ll explain why I did this coming up.
You can add the following markup in the button_component.html.erb
file. The component gives us access to the instance variables we initialized and any method names we created. This is like view helpers. The link helper accepts a block where we’ll conditionally render the label attribute if passed and otherwise render a content
assessor that comes by default with ViewComponent.
<%= link_to @href, class: button_theme do %>
<%= @label.present? ? @label : content %>
<% end %>
Understanding the content assessor
By default, every ViewComponent class gets a free content
assessor. If you have a block for your component, this can be what renders. Let’s look at an example.
<%= render(ButtonComponent.new(href: new_post_path, theme: :primary)) do %>
<span>Some content</span>
<% end %>
As shown in this example, the component can also pass a block, giving you more options for rendering buttons with icons or additional content. With this addition, our ButtonComponent
becomes quite flexible in terms of application, which I find very pleasant.
Add some styles
I added some basic styles to app/assets/stylesheets/application.tailwind.css
to improve the UI's look and feel. Of course, this can all be improved greatly.
/* app/assets/stylesheets/application.tailwind.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.btn {
@apply font-bold py-2 px-4 rounded inline-flex items-center justify-center text-center w-auto;
}
.btn-primary {
@apply bg-blue-500 text-white hover:bg-blue-700;
}
.btn-secondary {
@apply bg-gray-500 text-white hover:bg-gray-700;
}
.btn-white {
@apply bg-white text-gray-800 border border-gray-400 hover:bg-gray-100;
}
}
Using the component in views
To use your component in a view, you can render it like this:
<!-- app/views/posts/index.html.erb -->
<p style="color: green"><%= notice %></p>
<div class="flex items-center justify-between flex-wrap mb-6">
<h1 class="text-3xl font-bold tracking-tighter">Posts</h1>
<%= render(ButtonComponent.new(label: "New post", href: new_post_path, theme: :primary)) %>
</div>
<div id="posts">
<% @posts.each do |post| %>
<%= render post %>
<p>
<%= link_to "Show this post", post %>
</p>
<% end %>
</div>
This component will output:
<a href="/posts/new" class="btn btn-primary">Get started</a>
Conditional rendering
A nice advantage of ViewComponent is its built-in conditional support. You can encapsulate a typical conditional statement inside the component so it only renders based on the permissions you set. This is great for things like permission-level controls.
For example, say you only wanted to render this button for signed-in users. That could be as simple as the following:
# app/components/button_component.rb
# frozen_string_literal: true
class ButtonComponent < ApplicationComponent
def initialize(label: nil, href:, theme: :primary)
@label = label
@href = href
@theme = theme
end
def button_theme
case @theme
when :primary
"btn btn-primary"
when :secondary
"btn btn-secondary"
when :white
"btn btn-white"
else
"btn btn-primary"
end
end
def render?
user_signed_in?
end
end
Note the render?
method. This is built into components. In this method, you can pass some form of conditional that will dictate the presence of the component.
Be sure to update your posts_controller
with the before_action method that comes with devise
class PostsController < ApplicationController
before_action :authenticate_user!, only: %i[ new create edit update destroy ]
def create
# merge current user with post
@post = Post.new(post_params.merge(user: current_user))
end
# ...
end
Then remove the user_id
form field from app/views/posts/_form.html.erb
.
Creating a BlogPostComponent
Generate a new BlogPostComponent
. I’ll pass a post
option to feed the component data from specific blog posts.
bin/rails generate component BlogPost post
Now, extend the component to include simple methods in the template.
# app/components/blog_post_component.rb
# frozen_string_literal: true
class BlogPostComponent < ApplicationComponent
def initialize(post:)
@post = post
end
def title
@post.title
end
def date
@post.created_at.to_formatted_s(:long)
end
def author
@post.user.name || @post.user.email
end
def truncated_content
@post.content.truncate(200, separator: ' ')
end
end
Here’s a basic template to start with
<!-- app/components/blog_post_component.html.erb -->
<article class="p-6">
<h2 class="text-xl font-bold tracking-tight"><%= title %></h2>
<p class="text-sm leading-tight">
By <%= author %> on <%= date %>
</p>
<div class="text-base leading-tight">
<%= truncated_content %>
</div>
<%= render(ButtonComponent.new(label: "Read more", href: post_path(@post), theme: :white)) %>
</article>
Using the BlogPostComponent
Now, let's use this component in a view that displays the list of blog posts:
<!-- app/views/posts/index.html.erb -->
<h1>Latest Blog Posts</h1>
<div class="blog-posts">
<% @posts.each do |post| %>
<%= render(BlogPostComponent.new(post:post)) %>
<% end %>
</div>
Pretty nice! This isn’t a huge leap from helpers and partials, but components have many pros.
Creating a AvatarComponent
Let's create another component for displaying user avatars:
bin/rails generate component Avatar user size
Implement the component:
# app/components/avatar_component.rb
class AvatarComponent < ApplicationComponent
def initialize(user:, size: :md)
@user = user
@size = size
end
def avatar_url
default_avatar_url
end
def avatar_class
case @size
when :lg
"size-12 rounded-md"
when :md
"size-8 rounded-md"
when :sm
"size-6 rounded-md"
end
end
private
def default_avatar_url
"https://ui-avatars.com/api/?name=#{@user.name}&size=64"
end
end
And the template:
<!-- app/components/avatar_component.html.erb -->
<%= image_tag avatar_url, class: avatar_class, alt: @user.name, title: @user.name %>
Using Components Together
Now, let's enhance our BlogPostComponent
to use the AvatarComponent
:
# app/components/blog_post_component.rb
class BlogPostComponent < ApplicationComponent
def initialize(post:)
@post = post
end
def title
@post.title
end
def date
@post.created_at.to_formatted_s(:long)
end
def author
@post.user.name || @post.user.email
end
def truncated_content
@post.content.truncate(200, separator: ' ')
end
end
Update the BlogPostComponent template:
<!-- app/components/blog_post_component.html.erb -->
<article class="p-6 block">
<h2 class="text-3xl mb-3 font-bold tracking-tight"><%= title %></h2>
<div class="flex items-center mb-6">
<div class="mr-2">
<%= render(AvatarComponent.new(user: @post.user, size: :sm)) %>
</div>
<p><%= author %> on <%= date %></p>
</div>
<div class="text-lg leading-relaxed max-w-3xl mb-6">
<%= truncated_content %>
</div>
<%= render(ButtonComponent.new(label: "Read more", href: post_path(@post), theme: :white)) %>
</article>
These examples demonstrate creating and using ViewComponents in a more realistic scenario. The BlogPostComponent
and AvatarComponent
can be reused across different views, ensuring consistency and making it easier to maintain and update the UI of your Rails application.
Slots
In addition to the content
accessor, ViewComponents can accept content through slots. Think of
slots as rendering multiple content blocks, including other components. This is where components get neat!
To demonstrate slots, I’ll create a navigation component with navigation links. To keep things general, we’ll use a container and an item.
rails generate component nav/container
rails generate component nav/item title active href
rails generate component logo path
Note the namespace here for the first two generators. I did this to keep things more organized. This creates a subfolder in app/components,
though you must think modularly when rendering. I'll explain more about this later.
- For the
item
component, we pass three arguments:title,
active,
andhref.
- The logo is a global component we’ll use to render our logo. Pretty simple.
In app/components/nav/container_component.rb
, we can render many items using the view component's build DSL.
# app/components/nav/container_component.rb
class Nav::ContainerComponent < ApplicationComponent
renders_one :logo, LogoComponent
renders_many :items, Nav::ItemComponent
end
For now, I added basic markup to the template.
<div class="bg-slate-50 p-3 m-3 rounded-md flex items-center justify-between">
<% if logo? %>
<%= logo %>
<% end %>
<div class="flex-1">
<% items.each do |item| %>
<%= item %>
<% end %>
</div>
</div>
Thanks to the renders_many
and renders_one
slots, we can access these objects in the template.
Here’s the code for the nav/item_component.rb
and nav/item_component.html.erb
files
# app/components/nav/item_component.rb
# frozen_string_literal: true
class Nav::ItemComponent < ApplicationComponent
def initialize(title:, active: false, href:)
@title = title
@active = active
@href = href
end
def classes
"font-medium text-base #{active_class}"
end
def active_class
"text-blue-600 underline" if @active
end
end
<!-- app/components/nav/item_component.html.erb -->
<%= link_to @href, class: classes do %>
<%= @title.present? ? @title : content %>
<% end %>
The logo component
# app/components/logo_component.rb
# frozen_string_literal: true
class LogoComponent < ApplicationComponent
def initialize(path:)
@path = path
end
end
<!-- app/components/logo_component.html.erb -->
<%= link_to @path do %>
<svg class="size-10 stroke-current text-blue-600" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>star</title><g fill="none"><path d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.563.563 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.563.563 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></g></svg>
<% end %>
Putting this together, I updated our main layout to use the new navigation component.
<!DOCTYPE html>
<html>
<head>
<title>ViewComponentDemo</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>
</head>
<body>
<div class="container mx-auto px-4 py-12">
<%= render Nav::ContainerComponent.new do |c| %>
<%= c.with_logo path: root_path %>
<div class="flex-1">
<%= c.with_nav title: "Home", href: root_path, active: current_page?(root_path) %>
<% if user_signed_in? %>
<%= c.with_nav title: current_user.name, href: edit_user_registration_path, active: current_page?(edit_user_registration_path) %>
<% else %>
<%= c.with_nav title: "Sign in", href: new_user_session_path, active: current_page?(new_user_session_path) %>
<%= c.with_nav title: "Sign up", href: new_user_registration_path, active: current_page?(new_user_registration_path) %>
<% end %>
</div>
<% end %>
<div class="mt-16">
<%= yield %>
</div>
</div>
</body>
</html>
Here, we render the main Nav::ContainerComponent
with a block and use the renderables within. This gives us access to the logo
and nav
slots based on our naming conventions in the Nav::ContainerComponent
.
Pretty slick!
Testing the Components
We can’t forget about tests. For the sake of time, I won’t go through every component, but let's write a test for the BlogPostComponent.
The test file should already be present when you run the generator.
# test/components/blog_post_component_test.rb
# frozen_string_literal: true
require "test_helper"
class BlogPostComponentTest < ViewComponent::TestCase
setup do
@post = posts(:one)
end
test "renders blog post details" do
render_inline(BlogPostComponent.new(post: @post))
assert_text "Test Post"
assert_text "John Doe"
assert_text "July 29, 2024 22:17"
assert_text "This is test post content."
assert_link "Read more"
end
end
I’m using fixtures for my tests. You’ll want to ensure yours are set up beforehand.
# test/fixtures/users.yml
one:
first_name: "John"
last_name: "Doe"
email: "[email protected]"
two:
first_name: "Josh"
last_name: "Doe"
email: "[email protected]"
# test/fixtures/posts.yml
one:
title: "Test Post"
created_at: "July 29, 2024 22:17"
content: This is test post content.
user: one
two:
title: "Test Post 2"
created_at: "July 29, 2024 22:17"
content: This is more test post content.
user: two
This obviously isn’t very thorough, but hopefully, you can see how tests play a nice role in ensuring the functionality of a typical component.
To round out this tutorial, I’ll briefly provide an overview of ViewComponent's benefits and best practices.
Benefits of Using ViewComponent
- Encapsulation: Components encapsulate their logic, markup, and styling, making them easier to maintain and reuse.
- Testability: ViewComponents can be easily unit-tested, improving your application's overall test coverage.
- Performance: ViewComponents are designed to be fast and efficient, with built-in caching mechanisms.
- Flexibility: You can use ViewComponents alongside your existing Rails views and partials, allowing for gradual adoption.
- Consistency: Creating a library of reusable components ensures a consistent look and feel across your application. Using something like Tailwind paired with view_component can get you very far as your application scales.
Best Practices
- Name components based on what they render, not what they accept (e.g.,
AvatarComponent
instead ofUserComponent.
- Consider creating an
ApplicationComponent
that inherits fromViewComponent::Base
as a base for all your components. - Use the component generator to scaffold new components quickly.
- Leverage ViewComponent's support for ERB templates, Haml, or Slim for your component's markup.
Adopting ViewComponent in your Rails applications allows you to create more maintainable, testable, and reusable view code. As you become more comfortable with the library, you'll find it easier to break down complex UIs into manageable, encapsulated components that can be composed to create rich user interfaces. I’m planning on incorporating this into Rails UI (my other project) over time. Writing and recording this crash course has inspired me to push forward with it.
Categories
Collection
Part of the Ruby on Rails collection
Products and courses
-
Hello Hotwire
A course on Hotwire + Ruby on Rails.
-
Hello Rails
A course for newcomers to Ruby on Rails.
-
Rails UI
UI templates and components for Rails.