Notes to self

Understanding ViewComponent concepts by building a button

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

Work with me

I have some availability for contract work. I can be your fractional CTO, a Ruby on Rails engineer, or consultant. Write me at strzibny@strzibny.name.

RSS