I recently did a deep dive on refactoring Rails views to reusable view components.
I thought I’d share some of the components that were extracted from the example application used for this exercise with you here.
You can view the code for the refactored application in this repo.
LinkComponent
and its children
To cut down on duplicate link styling, I created a LinkComponent
template:
<%# app/components/link_component.html.erb %>
<div class="text-gray-600 transition duration-300 border-b border-white max-w-fit hover:border-b hover:border-gray-400">
<%= content %>
</div>
Since we’re just rendering styled content here, the associated spec is super simple:
# spec/components/link_component_spec.rb
require "rails_helper"
RSpec.describe LinkComponent, type: :component do
it "renders text" do
test_text = "some text"
render_inline(described_class.new.with_content(test_text))
expect(rendered_component).to have_text(test_text)
end
end
More specific link templates simply inherit from this base template. I decided to leave the link_to
methods in the various link components that inherit from LinkComponent
so that DeleteComponent
can also be part of the family. Before we take a look at the various link components’ code, here they are in action in the show team view (app/views/teams/show.html.erb
):
Four separate link templates are used in the view above. One each for:
- Back navigation out of the show view,
- Editing a team,
- Deleting a team, and
- Adding a player to the team.
All of their templates and specs are provided below, should you want to make use of something similar in your own projects.
Back navigation
Template:
<%# app/components/back_navigation_component.html.erb %>
<%= render LinkComponent.new do %>
<%= link_to @href do %>
<div class="flex items-center space-x-1">
<svg class="w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18">
</path>
</svg>
<div>
Back to <%= @text %>
</div>
</div>
<% end %>
<% end %>
Spec:
# spec/components/back_navigation_component_spec.rb
require "rails_helper"
RSpec.describe BackNavigationComponent, type: :component do
it "renders a back-navigation link" do
test_url = "https://example.com"
test_text = "team"
render_inline(described_class.new(href: test_url, text: test_text))
expect(rendered_component).to have_link(
"Back to #{test_text}",
href: test_url
)
end
end
Edit link
Template:
<%# app/components/edit_link_component.html.erb %>
<%= render LinkComponent.new do %>
<%= link_to @href do %>
<div class="flex items-center space-x-1">
<svg class="w-4" fill="none" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z">
</path>
</svg>
<div>
Edit <%= @text %>
</div>
</div>
<% end %>
<% end %>
Spec:
# spec/components/edit_link_component_spec.rb
require "rails_helper"
RSpec.describe EditLinkComponent, type: :component do
it "renders an edit link" do
test_url = "https://example.com"
test_text = "player"
render_inline(described_class.new(href: test_url, text: test_text))
expect(rendered_component).to have_link "Edit #{test_text}", href: test_url
end
end
Delete link
Template:
<%# app/components/delete_link_component.html.erb %>
<%= render LinkComponent.new do %>
<%= link_to @href, data: {
turbo_method: :delete,
turbo_confirm: "Are you sure?"
} do %>
<div class="flex items-center space-x-1">
<svg class="w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16">
</path>
</svg>
<div>
Delete <%= @text %>
</div>
</div>
<% end %>
<% end %>
Spec:
# spec/components/delete_link_component_spec.rb
require "rails_helper"
RSpec.describe DeleteLinkComponent, type: :component do
it "renders a delete link" do
test_url = "https://example.com"
test_text = "player"
render_inline(described_class.new(href: test_url, text: test_text))
expect(rendered_component).to have_link "Delete #{test_text}", href: test_url
end
end
Add link
Template:
<%# app/components/add_link_component.html.erb %>
<%= render LinkComponent.new do %>
<%= link_to @href do %>
<div class="flex items-center space-x-1">
<svg class="w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round"
stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6">
</path>
</svg>
<div>
Add a <%= @text %>
</div>
</div>
<% end %>
<% end %>
Spec:
# spec/components/delete_link_component_spec.rb
require "rails_helper"
RSpec.describe AddLinkComponent, type: :component do
it "renders an add link" do
test_url = "https://example.com"
test_text = "player"
render_inline(described_class.new(href: test_url, text: test_text))
expect(rendered_component).to have_link "Add a #{test_text}", href: test_url
end
end
A componentized page header
Once defined, component templates can easily be called. Here they are as part of the page header in the image up above:
<%# app/views/teams/show.html.erb %>
<div class="mb-2">
<%= render PageHeaderComponent.new do %>
<div class="mb-1">
<%= render BackNavigationComponent.new(
href: teams_path,
text: "teams"
) %>
</div>
<div class="mb-1">
<%= render PageTitleComponent.new(title: @team.name) %>
</div>
<div class="flex space-x-3">
<%= render EditLinkComponent.new(href: edit_team_path(@team), text: "team") %>
<%= render DeleteLinkComponent.new(href: team_path(@team), text: "team") %>
</div>
<% end %>
</div>
...
As you might expect, the PageHeaderComponent
and PageTitleComponent
templates are super simple:
<%# app/components/page_header_component.html.erb %>
<div class="pb-2 border-b">
<%= content %>
</div>
<%# app/components/page_title_component.html.erb %>
<div class="text-xl font-bold">
<%= @title %>
</div>
That’s the beauty of working with ViewComponent: define once, use anywhere.
If you enjoyed this brief look at ViewComponent templates, you might enjoy the full post. Check out the start-here
git branch and follow along from the comfort of your own editor as we refactor a fully-tested example application to reusable components.
Top comments (2)
This is the way. But now you are just one step away from doing it in JS/TS, with something like NextJS instead of Rails. Then you could even run those components on the client, not just the server. Truly "define once, use anywhere". [1]
The component code would be more readable too, since you don't need inline render statements and such:
but could have more HTML-like structure with JSX (where the component name is front and center):
That would also let you work in the context of JS, instead of working inside the context of HTML where you'd need to inline Ruby. Phlex would be the similar approach in Ruby, but syntax is even better in JSX, imho. Plus with JSX (React/Next.js) you get to run the same code on the client too [2], if you wish (without needing something extra like Stimulus to hook into and modify the HTML after the fact).
If you only care about web dev (not mobile / react native), and want to be really cutting edge, then I'd ditch Next.js in favor of Qwik City. That would also let you run the same code on the server and the client, but Qwik City can also start the execution on the server, and resume it seamlessly on the client (and only ever download the minimal JS needed to the client, eliminating JS bundle size bloat).
[1] If you want to take the "write once, run anywhere" philosophy to the max, then with Tamagui you could even write once and run on iOS, Andoid as well as the Web! That's if you care about being fully cross-platform.
[2] The alternative (if you eventually need rich client side interaction): Github had to implement their design system twice, once on the backend with ViewComponent, and once on the frontend with Primer React, which corresponds. primer.style/ See also reddit thread on it.
Nice one Stef. Great to see accompaning spec examples as well.
Going to take some time to go over this proper.