Vanilla Rails view components with partials

Many projects I work on have some kind of view component that is repeated multiple times in the same view, or is present in multiple different views. These view components can be anything that has a specific styling, JavaScript specific attributes (like Stimulus controllers), rendering logic, and HTML element structure that always has to be the same for the component to render properly - like cards, containers, content boxes, modals, lists, tables, and so on.

There are three ways that I have seen people work with view components in Rails - by copy-pasting them around, by using one of the view component gems, and partials.

Copy-pasting, even when using CSS conventions like BEM, has the downside of being laborious to update should the component ever change. And I find the gems to be unnecessary since vanilla Rails already has all the features to render and manage view components through partials.

This is an example of three different view components combined together to render a table-like list and a map within a content box with a title using just partials.
Code and browser view of multiple vanilla Rails view components composed together to show location details and a map side-by-side within a titled content box.
Code and browser view of multiple vanilla Rails view components composed together to show location details and a map side-by-side within a titled content box.

The component method might seem like there is a lot of magic under the hood, but there really isn’t. I added this method through a view helper and it does just two things - it calls render with "components/#{component_name}" so that I don’t have to write the same incantation all over the place, and I’ll explain the the second purpose later.

For now, this is what the helper looks like
#!/usr/bin/ruby
module ComponentHelper
  def component(path, options = {}, &block)
    full_path = Pathname.new("components") / path
    render(full_path.to_s, options)
  end
end
So component("map", longitude: 15.9765701, latitude: 45.8130054) is the same thing as render("components/map", longitude: 15.9765701, latitude: 45.8130054).

Partials allow you to render a snippet of a view wherever you like. For example, instead of having to repeat all the HTML elements and Ruby code that renders two tables:
<!-- app/views/matches/show.html.erb -->
<h2>Winners</h2>
<table>
  <thead>
    <tr>
      <th>Rank</th>
      <th>Player</th>
      <th>Score</th>
    </tr>
  </thead>
  <tbody>
    <%= @match.winners.each do |player| %>
      <tr>
        <td><%= player.rank %></td>
        <td><%= player.name %></td>
        <td><%= player.score %></td>
      </tr>
    <% end %>
  </tbody>
</table>

<h2>Losers</h2>
<table>
  <thead>
    <tr>
      <th>Rank</th>
      <th>Player</th>
      <th>Score</th>
    </tr>
  </thead>
  <tbody>
    <%= @match.losers.each do |player| %>
      <tr>
        <td><%= player.rank %></td>
        <td><%= player.name %></td>
        <td><%= player.score %></td>
      </tr>
    <% end %>
  </tbody>
</table>
With partials you can just render the partial once for each table and pass it a local with the content it should render:
<!-- app/views/matches/show.html.erb -->
<h2>Winners</h2>
<!-- we are passing `@match.winners` as the local `players` to the partial -->
<%= render "players_table", players: @match.winners %>

<h2>Losers</h2>
<%= render "players_table", players: @match.losers %>

<!-- app/views/matches/_players_table.html.erb -->
<table>
  <thead>
    <tr>
      <th>Rank</th>
      <th>Player</th>
      <th>Score</th>
    </tr>
  </thead>
  <tbody>
    <!-- the `players` local shows up in this partial as the `players` variable -->
    <%= render collection: players, partial: "matches/player" %>
    <!-- the above ^ is a shorthand for:
    ```
      <% players.each do |player| %>
        <%= render "matches/player", player: player %>
      <% end %>
    ```
    -->
  </tbody>
</table>

<!-- app/views/matches/_player.html.erb -->
<tr>
  <td><%= player.rank %></td>
  <td><%= player.name %></td>
  <td><%= player.score %></td>
</tr>
Using partials like this works for components that have predetermined content (like tables, maps and lists), but it’s a pain for components that can have anything inside them (like containers, content boxes and modals).

You could extract the content of each component into its own partial and pass the the name of the partial you want to render.
<!-- app/views/teams/show.html.erb -->
<%= render "content_box", content_partial: "weekly_metrics", content_options: { team: @team } %>
<%= render "content_box", content_partial: "monthly_metrics", content_options: { team: @team } %>

<!-- app/views/teams/_content_box.html.erb -->
<section>
  <%= render content_partial, content_options %>
</section>

<!-- app/views/teams/_weekly_metrics.html.erb -->
<p>Matches won: <%= team.matches.where(created_at: (1.week.ago...)).won.count %></p>
<p>Matches lost: <%= team.matches.where(created_at: (1.week.ago...)).lost.count %></p>
<p>Upcoming matches: <%= team.matches.where(created_at: (Time.current...)).count %></p>

<!-- app/views/teams/_monthly_metrics.html.erb -->
<p>Matches won: <%= team.matches.where(created_at: (1.month.ago...)).won.count %></p>
<p>Matches lost: <%= team.matches.where(created_at: (1.month.ago...)).lost.count %></p>
This can be avoided by passing a block to the partial and yielding to it.
<!-- app/views/teams/show.html.erb -->
<%= render "content_box" do %>
  <p>Matches won: <%= @team.matches.where(created_at: (1.week.ago...)).won.count %></p>
  <p>Matches lost: <%= @team.matches.where(created_at: (1.week.ago...)).lost.count %></p>
  <p>Upcoming matches: <%= @team.matches.where(created_at: (Time.current...)).count %></p>
<% end %>

<%= render "content_box" do %>
  <p>Matches won: <%= @team.matches.where(created_at: (1.month.ago...)).won.count %></p>
  <p>Matches lost: <%= @team.matches.where(created_at: (1.month.ago...)).lost.count %></p>
<% end %>

<!-- app/views/teams/_content_box.html.erb -->
<section>
  <%= yield %>
  <!-- `yield` will be replaced by whatever is passed in the block -->
</section>
That’s how component("content_box", title: "Location") do works.
<!-- app/views/properties/show.html.erb -->
<% component("content_box", title: "Location") do %>
  <h1>Hello World!</h1>
<% end %>

<!-- app/views/components/_content_box.html.erb -->
<section class="rounded shadow bg-white">
  <!-- `local_assigns` is a hash that contains all the locals passed to a partial -->
  <!-- we can check if a local is present and access it's value through it -->
  <% if local_assigns.key?(:title) %>
    <h4 class="text-gray-900 text-lg">
      <%= local_assigns[:title] %>
      <!-- I could have used `title` instead of `local_assigns[:title]` -->
    </h4>
  <% end %>
  <div>
    <%= yield %>
  </div>
</section>
This brings me back to the second purpose of the component method - fixing localization.

If I would use full localization keys for everything then there wouldn’t be a problem with just using render
<!-- app/views/properties/show.html.erb -->
<% render("components/content_box", title: t("properties.show.location")) do %>
  <h1><%= t("properties.show.hello_world") %></h1>
<% end %>

<!-- config/locales/en.yml -->
en:
  properties:
    show:
      location: "Location"
      hello_world: "Hello World!"

<!-- rendered HTML -->
<section class="rounded shadow bg-white">
  <h4 class="text-gray-900 text-lg">Location</h4>
  <div>
    <h1>Hello World!</h1>
  </div>
</section>
But I’m lazy and like to use relative localization keys everywhere, which causes a problem.
<!-- app/views/properties/show.html.erb -->
<% render("components/content_box", title: t(".location")) do %>
  <h1><%= t(".hello_world") %></h1>
<% end %>

<!-- config/locales/en.yml -->
en:
  properties:
    show:
      location: "Location"
      hello_world: "Hello World!"

<!-- rendered HTML -->
<section class="rounded shadow bg-white">
  <h4 class="text-gray-900 text-lg">Location</h4>
  <div>
    <h1>
      <!-- This is what Rails render when a translation is missing -->
      <!-- Notice that it looked for the translation in `en.components.content_box.hello_world` -->
      <!-- instead of in `en.properties.show.hello_world` -->
      <span class="translation_missing" title="translation missing: en.components.content_box.hello_world">
        Hello World
      </span>
    </h1>
  </div>
</section>
With relative localization keys, Rails prefixed the key with the path of the component instead of the parent.

When a view yields the passed block is rendered in the context of that view, not in the context of the view where the block was created. Since the partial yields the relative localization key prefix is components.content_box.

To solve this I can either use full localization keys within components, which is annoying; or I can add the translation to the component’s localization, which means that I would have to think about possible key collisions when using relative localization keys within components.

Thankfully there is a way to capture a part of a view within the context of the current view and store it as a variable. With the capture method I can yield the block passed to the component in the context of the current view, store the result to a variable, and pass that variable in a block to render.

Here is the full ComponentHelper code with the relative locale keys fix.
#!/usr/bin/ruby
module ComponentHelper
  def component(path, locals = {}, &block)
    full_path = Pathname.new("components") / path

    if block
      # Render the passed block within the current context and
      # store it in the `content` variable
      content = capture do
        block.call
      end

      # Call render but pass it a new block that yields just
      # the contents of the `content` variable
      render(full_path.to_s, locals) { content }
    else
      render(full_path.to_s, locals)
    end
  end
end
And that’s all there is to it - render, local_assigns, yield and capture.
Subscribe to the newsletter to receive future posts via email