The ViewComponent library from GitHub is becoming a popular answer to building design systems in server-rendered Rails applications. Let’s understand the basics by creating a fancy component button.
What’s in the component
For the purposes of this post, a component is an encapsulation of a reusable piece of a view template responsible for its rendering. Components can technically be unique, but the core idea is to build common reusable parts and stay away from one-off components. Think buttons, alerts, or icons.
At its core, a component in a ViewComponent system is just a piece of Ruby code. It’s an object that calls its call
method for rendering content:
class LinkButtonComponent < ViewComponent::Base
# def initialize
# end
# def call
# end
end
We can render such a component with the render
helper:
<!-- in a template -->
<%= render(LinkButtonComponent.new) ...
# in a controller
def show
render(LinkButtonComponent.new)
end
Without providing a call
method, the component will simply take and render the block you provide:
<!-- in a template -->
<%= render(LinkButtonComponent.new) do %>
<%= link_to "My button", page_path %>
<% end %>
But you can always define what’s going to be rendered by overriding call
:
class LinkButtonComponent < ViewComponent::Base
def initialize
end
def call
link_to "My button", page_path
end
end
Instead of a Rails view tag, you could also provide HTML directly with raw
or html_safe
.
Templates
Since components should stay flexible, I’ll change the above component to accept the label as argument while keeping the link_to
inside it:
class LinkButtonComponent < ViewComponent::Base
def initialize(label:, url:)
@label = label
@url = url
end
def call
link_to @label, @url
end
end
<!-- later -->
<%= render(LinkButtonComponent.new("Click me!")) %>
However, components are usually not just Ruby classes. They usually feature an accompanying template.
The following demonstrates how the call
method renders its counterpart template by default:
# link_button_component.rb
class LinkButtonComponent < ViewComponent::Base
def initialize(label:, url:)
@label = label
@url = url
end
end
<!-- link_button_component.html.erb -->
<%= link_to @label, @url %>
Templates can also use methods from the components to keep things more tidy or flexible:
# link_button_component.rb
class LinkButtonComponent < ViewComponent::Base
SIZES = [
:small,
:medium,
:large,
].freeze
def initialize(label:, url:, size: :medium)
@label = label
@url = url
@size = size
end
def choose_size
SIZES[@size] || :medium
end
# If not using the template
# def call
# link_to @label, @url, class: choose_size
# end
end
<!-- link_button_component.html.erb -->
<%= link_to @label, @url, class: choose_size %>
<!-- later -->
<%= render(LinkButtonComponent.new(label: "Click me!", url: @url)) %>
After passing our generic data via an initializer, we can decide whether it is more sense to override the call
method or provide a template. We can still call methods from the component in both cases.
Content
A key part of realizing when building components is to realize that the passed block is saved in the @content
variable, and @content
is what’s rendered if there is no template. The link button above didn’t need any of that, so let’s turn the component into a button_tag
wrapper and pass the inner content as a block:
# button_component.rb
class ButtonComponent < ViewComponent::Base
def initialize(data:, **attrs)
@data = data
@attrs = attrs
end
end
<!-- button_component.html.erb -->
<%= button_tag data: @data, **@attrs do %>
<%= content %>
<% end %>
<!-- later -->
<%= render(ButtonComponent.new(class: "button", data: {})) do %>
Click me!
<% end %>
Here “Click me!” is passed as a block that’s accessible as @content
within the component class and as content
in the template. Since data
is required, it’s passed explicitly, but any other button_tag
argument is just carried over by leveraging the double splat operator.
If we have a small piece of content, we can also call with_content
on the component:
<%= render(ButtonComponent.new(class: "button", data: {})).with_content("Click me!") %>
@content
can also be provided or modified by us. An example could be providing a default with before_render
:
# button_component.rb
class ButtonComponent < ViewComponent::Base
def initialize(data:, **attrs)
@data = data
@attrs = attrs
end
def before_render
@content ||= "Click me!"
end
end
<!-- later -->
<%= render(ButtonComponent.new(class: "button", data: {})) %>
Slots
Slots are the butter for ViewComponent. They extend the content capabilities of a component to define additional named content areas. A button could have one icon slot, a group of buttons could have many button slots, or an alert could have a headline slot.
Let’s say that our button should accept an icon and a label slot. Since they are both unique, we define them using renders_one
:
# button_component.rb
class ButtonComponent < ViewComponent::Base
renders_one :icon
renders_one :label
def initialize(data:, **attrs)
@data = data
@attrs = attrs
end
end
A basic slot can render what’s passed to it (and we can find out if it’s passed with ?
method):
<!-- button_component.html.erb -->
<%= button_tag data: @data, **@attrs do %>
<% if icon? %>
<div class="button-icon">
<%= icon %>
</div>
<% end %>
<%= label %>
<% end %>
<!-- later -->
<%= render(ButtonComponent.new(class: "button", data: {})) do |c| %>
<%= c.with_icon do %>
<i class="bi bi-alt"></i>
<% end %>
<%= c.with_label do %>
Click me!
<% end %>
<% end %>
A lot of times, though, we would delegate the slot to a component:
# button_component.rb
class ButtonComponent < ViewComponent::Base
renders_one :icon, "IconComponent"
class IconComponent < ViewComponent::Base
attr_reader :classes
def initialize(classes: "")
@classes = classes
end
def call
content_tag :i, "", { class: classes }
end
end
def initialize(data:, **attrs)
@data = data
@attrs = attrs
end
end
Notice that the component is inside. If you make a component like that, you need to reference it with a string instead of a symbol. The template doesn’t look that different (the icon component could still accept a block):
<%= render(ButtonComponent.new(class: "button", data: {})) do |c| %>
<%= c.with_icon(classes: "bi bi-alt") %>
<%= c.with_label do %>
Click me!
<% end %>
<% end %>
Finally, let’s create a button group that will group any number of these buttons:
# button_group_component.rb
class ButtonGroupComponent < ViewComponent::Base
renders_many :buttons, ButtonComponent
end
Here we define the slots with renders_many
. Don’t forget the name is now plural.
In the component template, though, the usage is practically the same as for single slots:
<!-- button_group_component.html.erb -->
<div class="buttons">
<% buttons.each do |button| %>
<%= button %>
<% end %>
</div>
And the same is true for regular templates:
<%= render(ButtonGroupComponent.new) do |c| %>
<%= c.with_button(data: {}) do |b| %>
<%= b.with_label do %>
Click me!
<% end %>
<% end %>
<%= c.with_button(data: {}) do |b| %>
<%= b.with_label do %>
Click me too!
<% end %>
<% end %>
<% end %>
There are other nice ways to work with collections, so make sure to read the linked documentation.
Conditionals
As a last concept for this introduction, I want to mention conditional rendering. Now that we have a button group, we could extend it to be rendered only for admins if necessary. The rendering is controlled with render?
:
# button_group_component.rb
class ButtonGroupComponent < ViewComponent::Base
renders_many :buttons, ButtonComponent
def initialize(user:)
@user = user
end
def render?
@user.admin?
end
end
If render?
is evaluated to false
, nothing gets rendered. No more conditionals inside views:
<%= render(ButtonGroupComponent.new(user: Current.user)) do |c| %>
...
<% end %>
Testing
ViewComponent has a decent testing guide. The core idea is that we render components inline and then assert by semantic elements or classes:
require "test_helper"
class ButtonComponentTest < ActiveSupport::TestCase
include ViewComponent::TestHelpers
test "renders a basic component" do
render_inline(ButtonComponent.new(data: {}))
assert_selector("button", text: "Click me!")
end
...
Or, in case of slots:
require "test_helper"
class ButtonGroupComponentTest < ActiveSupport::TestCase
include ViewComponent::TestHelpers
test "renders buttons" do
render_inline(ButtonGroupComponent.new(user: User.new) do |component|
component.with_button(data: { action: "click->action" }) { "Ok" }
...
end
assert_selector("button[data-action=\"click->action\"]", text: "Ok")
...
end
Get Test Driving Rails while it's in prerelease.