I've been hearing a lot about this library called ViewComponent from Github, and I wanted to see what the excitement is all about.
So after spending a few days playing with the library, I was surprised by what I've found.
There is one feature in particular that surprised me the most, and I want to tell you all about it.
But first, let's take a quick look at all of its features.
Here's a video version of this article if you prefer to watch instead.
As a first-time user of the library, I must say that the homepage doesn't do much in terms of answering some of the burning questions that I had before I started playing with the library.
Questions like:
- Why do I need this?
- What problem does it solve?
- What's wrong with the standard Rails approach?
Keep in mind that this library was developed at Github to solve a very specific problem that they had.
And the scale at which Github operates is very different than the scale at which most Rails applications operate.
So that's just something to keep in mind.
Now, on their homepage there are two big selling points.
- The first is better OO design (through single responsibility, testing, and dependency injection).
- And the second is performance.
So let's address both of these first, and then I'll tell you what I think the big feature actually is.
Better OO design
In terms of improving your Rails app's OO design, I think the library does a decent job, but I'm not entirely sure if it's worth the investment.
It tries to reinvent Rails' views through the component architecture, where you have simple Ruby objects that are nicely encapsulated, making interactions and dependencies explicit.
And that's a big goal, but I don't think they are quite there yet. Or if they'll ever be.
While some dependencies are made explicit, like everything you pass into the constructor; others are not. Like the Rails' view context, which gives you access to the current controller and all the global view helpers.
So unless you reinvent the wheel and rewrite the Rails helpers, you'll probably need to access that view context a lot.
And that creates coupling.
Testing
There's also this statement on their homepage that doesn't sit well with me.
In their words:
Unlike traditional Rails templates, ViewComponents can be unit tested.
It's either the folks at Github didn't hear about view tests by now (which I doubt), or I'm missing something in their claim.
Then they also mention that:
In the GitHub codebase, ViewComponent unit tests are over 100x faster than similar controller tests.
Well... doh! Of course they are. Unit tests are faster than integration tests.
We already knew this. But they also serve totally different purposes.
And unit-testing your view components doesn't mean you don't have to write integration tests anymore.
Rails views are usually dumb, and they change a lot for reasons other than behavior changes. And that's why unit-testing views isn't always worth the effort.
An example of a view change could be to change the button text from "Buy" to "Add to cart". And you don't want your tests to fail when you do that, because it's not a change in behavior.
That's just an example, which is easy to fix with translations. But the point is, the view is an implementation detail.
As long as the user can achieve his task, we're good. Doesn't matter if the button is blue or green, or if the text is smaller or bigger, or if the sidebar is on the left or on the right.
Those are presentation details that should change a lot but without making your tests fail.
So, I'm not really sold on this one.
Performance
Now let's talk about performance.
This one sounds like a valid selling point, even though it's not the most attractive. I'll get to the one that is in a second.
I've seen some benchmarks suggesting that this claim is indeed valid. Even though it might not be 10x, it's still an improvement over partials.
So if your project does require a faster render for partials, and you think that the effort is worth it, sure. Go for it.
So that's nice.
The big reason why
But here's a somewhat hidden benefit that I find much more interesting.
And that is the ability to create design systems similar to how you would do it in React, but without having to build a single-page-app, or even use React at all.
A set of pluggable components that work well with Hotwire and that you don't have to build yourself every time you create a new project would indeed be helpful.
For that reason alone, I think ViewComponent is worth exploring.
So let's try to build a ViewComponent, and see if we can use it with Hotwire.
For this example, I will build a form component for creating new users.
And I'll start with a new Rails project using importmaps and TailwindCSS.
rails new view_component
cd view_component
bundle add tailwindcss-rails
rails tailwindcss:install
Then I'll generate a new User
model with a field for the name, and another for the email.
rails g model User name email
rails db:migrate
I'll also need a controller for the home page, which I'll name SiteController
, and another for creating users, name UsersController
.
rails g controller SiteController index
rails g controller UsersController new create
Every controller needs some routes, so let's add some in the routes.rb
file.
# config/routes.rb
Rails.application.routes.draw do
resources :users
root "site#index"
end
Our SiteController
needs to initialize a User
object, and fetch the list of users that will be displayed on the homepage.
# app/controllers/site_controller.rb
class SiteController < ApplicationController
def index
@user = User.new
@users = User.all
end
end
Moving on to our UsersController
, we'll add the logic for creating new users.
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def new
end
def create
@user = User.new(user_params)
if @user.save
flash[:notice] = "User saved successfully"
else
render :new, status: :unprocessable_entity
end
end
private
def user_params
params.require(:user).permit(:name, :email)
end
end
Now let's add the view_component
gem, and generate our first component.
bundle add view_component
rails g component NewUserForm --stimulus
The --stimulus
flag is used to generate the SimulusJS file for the component.
So the Ruby component will simply initialize a User
object.
# app/components/new_user_form_component.rb
class NewUserFormComponent < ViewComponent::Base
def initialize(user_class: User)
@user = user_class.new
end
end
And the component template, will contain the HTML code for the form.
<!-- app/components/new_user_form_component.html.erb -->
<div data-controller="new-user-form-component" class="my-8">
<%= form_with(model: @user, method: :post, class: "grid grid-cols-1 gap-6 block") do |f| %>
<%= f.label :name, class: "block" do %>
<%= tag.span "Full name", class: "text-gray-700" %>
<%= f.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" %>
<% end %>
<%= f.label :name, class: "block" do %>
<%= tag.span "Email", class: "text-gray-700" %>
<%= f.email_field :email, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50", placeholder: "jdoe@email.com" %>
<% end %>
<%= f.submit "Save", class: "bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" %>
<% end %>
</div>
We'll create a view helper method to wrap this object.
# app/helpers/users_helper.rb
module UsersHelper
def new_user_form
render NewUserFormComponent.new
end
end
And adjust the application.html.erb
layout a little bit to make some room.
<!-- app/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
<title>ViewComponent</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body>
<main class="w-1/2 mx-auto mt-28 px-5 relative">
<div class="flex justify-evenly"><%= yield %></div>
</main>
</body>
</html>
On the homepage, we'll call our helper to display the form component, and we'll also add a section for listing all the users.
<!-- app/views/site/index.html -->
<div id="new-user-form">
<%= new_user_form %>
</div>
<div id="users" class="prose px-4 py-2">
<h2>Users list</h2>
<%= users(@users) %>
</div>
That users
helper will display one user per line. But we don't have that helper defined yet. So let's add it, and also add another for displaying the details for a single user.
# app/helpers/users_helper.rb
def users(users)
render UserDetailsComponent.with_collection(users)
end
def user_details(user)
render UserDetailsComponent.new(user: user)
end
And since we don't have that component, let's create it.
rails g component UserDetails --stimulus
# app/components/user_details_component.rb
class UserDetailsComponent < ViewComponent::Base
with_collection_parameter :user
def initialize(user:)
@user = user
end
end
<!-- app/components/user_details_component.html.erb -->
<div data-controller="user-details-component" class="animate-flash-increase">
<%= @user.name %>, (<%= @user.email %>)
</div>
This CSS class will be used to add a little bit of animation when the record gets appended to the list.
/* app/assets/stylesheets/application.tailwind.css */
.animate-flash-increase {
animation: flash-yellow 0.5s ease-in-out;
}
@keyframes flash-yellow {
from {
background-color: #FFCE48;
}
to {
background-color: transparent;
}
}
But for TailwindCSS to pick up the classes used in those erb
templates, we need to add the app/components
folder to the tailwind.config.js
file.
// config/tailwind.config.js
const defaultTheme = require("tailwindcss/defaultTheme");
module.exports = {
content: [
"./public/*.html",
"./app/helpers/**/*.rb",
"./app/components/**/*.html.erb",
"./app/javascript/**/*.js",
"./app/views/**/*.{erb,haml,html,slim}",
],
theme: {
extend: {
fontFamily: {
sans: ["Inter var", ...defaultTheme.fontFamily.sans],
},
},
},
plugins: [
require("@tailwindcss/forms"),
require("@tailwindcss/aspect-ratio"),
require("@tailwindcss/typography"),
],
};
Before we can test this in the browser, we'll need to add the Turbo Stream response for the create
action.
<%# app/views/users/create.turbo_stream.erb %>
<%= turbo_stream.update "flash" do %>
<%= flash_alert %>
<% end %>
<%= turbo_stream.update "new-user-form" do %>
<%= new_user_form %>
<% end %>
<%= turbo_stream.append "users" do %>
<%= user_details(@user) %>
<% end %>
Now let's add the final component, which is going to be the AlertComponent
.
rails g component Alert --stimulus
# app/components/alert_component.rb
class AlertComponent < ViewComponent::Base
def initialize(message, type: :notice)
@message = message
@type = type
end
end
<!-- app/components/alert_component.html.rb -->
<div data-controller="alert-component">
<div class="relative bg-teal-100 border-t-4 border-teal-500 rounded-b text-teal-900 px-4 py-3 shadow-md" role="alert">
<div class="flex">
<div class="py-1"><svg class="fill-current h-6 w-6 text-teal-500 mr-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M2.93 17.07A10 10 0 1 1 17.07 2.93 10 10 0 0 1 2.93 17.07zm12.73-1.41A8 8 0 1 0 4.34 4.34a8 8 0 0 0 11.32 11.32zM9 11V9h2v6H9v-4zm0-6h2v2H9V5z"/></svg></div>
<div>
<p class="font-bold">Notice</p>
<p class="text-sm"><%= @message %></p>
</div>
</div>
<span class="absolute top-0 bottom-0 right-0 px-4 py-3" data-action="click->alert-component#close">
<svg class="fill-current h-6 w-6 text-teal-500" role="button" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><title>Close</title><path d="M14.348 14.849a1.2 1.2 0 0 1-1.697 0L10 11.819l-2.651 3.029a1.2 1.2 0 1 1-1.697-1.697l2.758-3.15-2.759-3.152a1.2 1.2 0 1 1 1.697-1.697L10 8.183l2.651-3.031a1.2 1.2 0 1 1 1.697 1.697l-2.758 3.152 2.758 3.15a1.2 1.2 0 0 1 0 1.698z"/></svg>
</span>
</div>
</div>
// app/components/alertcomponent_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
close() {
this.element.remove();
}
}
This one also has some Javascript code for handling the close button. The data-action
attribute is what handles the click
event on the button.
We're also going to wrap this one in a helper, and put it both inside the application.html.erb
layout, and the create.turbo_stream.erb
template.
# app/helpers/application_helper.rb
module ApplicationHelper
def flash_alert
render(AlertComponent.new(flash.notice)) if flash.notice
end
end
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
<title>ViewComponent</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body>
<main class="w-1/2 mx-auto mt-28 px-5 relative">
<div id="flash" class="mb-8 absolute right-0 -top-20 w-full">
<%= flash_alert %>
</div>
<div class="flex justify-evenly"><%= yield %></div>
</main>
</body>
</html>
<%# app/views/users/create.turbo_stream.erb %>
<%= turbo_stream.update "flash" do %>
<%= flash_alert %>
<% end %>
All of this is almost done. Except we need to configure our application to load the StimulusJS files inside the component folders.
So add this line to the manifest.js
file.
// app/assets/config/manifest.js
//= link_tree ../../components .js
Next, add the following two lines to the controllers/index.js
file.
// app/javascript/controllers/index.js
import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading";
lazyLoadControllersFrom("components", application);
And this code to your application.rb
file.
# config/application.rb
initializer "app_assets", after: "importmap.assets" do
Rails.application.config.assets.paths << Rails.root.join('app') # for component sidecar js
end
# Sweep importmap cache for components
config.importmap.cache_sweepers << Rails.root.join('app/components')
Then add this line to the importmap.rb
file.
# config/importmap.rb
pin_all_from "app/components", under: "components"
Conclusion
Here are some of the things I like about this library and some of the things I don't like about it.
Starting with the things I like...
- Components architecture
- Reusable components
- Design systems
- Github
If it gets more popular or even integrated into Rails at some point in the future, it will change how we build Rails views. In a good way.
The potential to reuse components across projects is what excites me the most about it. Because it can cut down the time and the boilerplate code you need to write with every new project.
Another thing I like is being able to create design systems that help you build a more consistent UI, especially if you work in a large team.
If you have a design system built for you, it significantly reduces the time you need to think about or tinker with various UI elements.
Also worth mentioning is that the company behind it is Github, so it's very likely they'll maintain the library in the future.
But there are also a few things I don't like about it...
- Not quite there yet
- Built for large projects
- Lagging updates
While I do see a lot of potential here, I also feel like there are currently some rough edges around dependency management and Hotwire integration. Which I hope will be solved at some point in the future.
I believe the goal of the library isn't necessary to make these components easy to reuse across projects or even to go full-on component architecture but to improve existing projects through refactoring.
So to me this is a tool for managing view complexity. Namely, when your view logic becomes unmanageable, you can move that logic into unit-testable components.
That makes a lot of sense, but mostly when working on large Rails projects.
Another thing that I would consider before using it is it's a 3rd party library. And as you can see, there are some open issues around Rails integration, especially with Hotwire, that have not been resolved for almost a year.
So you're taking on a bit of a risk if you depend on it too much.
That's just a consequence of using a 3rd party library and something to consider. But not necessarily a fault of the library itself.
All that being said, I'm really excited to see how this library evolves in the near future.