When displaying a lot of tabular data as we do at Wrapbook, it can become difficult to manage the individual columns that must render within the table. This can be because an addition or removal needs to be made, or because the rendering is driven by a feature flag.

A typical erb HTML table might look something like this:

<table>
  <thead>
    <tr>
      <th>ID</td>
      <th>Name</td>
      <th>Email</td>
      <th>Actions</td>
    </tr>
  </thead>

  <tbody>
    <%= @users.each do |user| %>
      <tr>
        <td><%= user.id %></td>
        <td><%= user.name %></td>
        <td><%= user.email %></td>
        <td><%= remove_button(user) %></td>
      </tr>
    <% end %>
  <tbody>

  <tfooter>
    <tr></tr>
  </tfooter>
</table>

For a simple table like this, with only 4 columns and simple markup, this is already very maintainable. Adding additional columns, reordering them and removing them is generally straightforward with the above code.

Where it gets more problematic is when our code needs to look like the following:

<table>
  <thead>
    <tr>
      <th>ID</td>
      <th>Name</td>
      <% if Flipper.enabled?(:email_display) %>
        <th>Email</td>
      <% end %>
      <th>Actions</td>
    </tr>
  </thead>

  <tbody>
    <%= @users.each do |user| %>
      <tr>
        <td><%= user.id %></td>
        <td><%= user.name %></td>
        <% if Flipper.enabled?(:email_display) %>
          <td><%= user.email %></td>
        <% end %>
        <td><%= remove_button(user) %></td>
      </tr>
    <% end %>
  <tbody>

  <tfooter>
    <tr>
      <th>ID</td>
      <th>Name</td>
      <% if Flipper.enabled?(:email_display) %>
        <th>Email</td>
      <% end %>
      <th>Actions</td>
    </tr>
  </tfooter>
</table>

In order for us to control the rendering of a given column, we have to add conditionals to the table head, body, and footer. Adding another column or changing the order of the table columns is similarly difficult. If the tables in our views are starting to look more like the above, it might be worthwhile to render them by column instead.

Rendering by Column?

When a view / partial in Rails is read in for rendering, it is done so from a top down manner. But because of the structure of HTMl tables, this generally means the only way we can breakup a complex table is to do something like have a partial for the header, a partial for the body, and a partial for the footer. That might look something like the following:

<table>
  <%= render "table_header" %>
  <%= render "table_body" %>
  <%= render "table_footer" %>
</table>

This is certainly a potential abstraction - but it only makes managing the columns more difficult! Instead of 1 file to update, we now have to update 3. Not only that, but if we’re changing any of the column ordering, we’ll need to take extra care that all of our headers and footers match up with the columns to render.

What if we could structure our HTML table to be more adaptive to changes? What if adding another column didn’t require updates to 3 sperate files, but the creation of a new one?

Introducing content_for

From the Rails docs

The content_for method allows you to insert content into a named yield block in your layout.

And later goes on to say

The content_for method is very helpful when your layout contains distinct regions such as sidebars and footers that should get their own blocks of content inserted. It’s also useful for inserting tags that load page-specific JavaScript or CSS files into the header of an otherwise generic layout.

By using content_for we are able to separate out the rendering of content from where it appears in the HTML. This lets our erb markup look something like this

<table>
  <thead>
    <tr><%= content_for :table_header %></tr>
  </thead>

  <tbody>
     <%= content_for :table_body %>
  <tbody>

  <tfooter>
    <tr><%= content_for :table_footer %></tr>
  </tfooter>
</table>

In order to use this, we’ll need to populate the table_body. We can do that with something like this using the new ability to omit values when passing in a user:

<% content_for(:table_body) do %>
  <% users.each do |user| %>
    <tr>
      <%= render "user_table/id", user: %>
      <%= render "user_table/name", user: %>
      <%= render "user_table/email", user:  %>
      <%= render "user_table/actions", user:  %>
    </tr>
  <% end %>
<% end %>

<table>
  <thead>
    <tr><%= content_for :table_header %></tr>
  </thead>

  <tbody>
     <%= content_for :table_body %>
  <tbody>

  <tfooter>
    <tr><%= content_for :table_footer %></tr>
  </tfooter>
</table>

That takes care of rendering the table_body but what do we need to do to make the header and footer columns populate? Thankfully, nothing!

This is what the user_table/id.html.erb file that we render out above looks like. We have additional content_for blocks that will append to the table_header and table_footer content to be subsequently rendered out.

<% content_for(:table_header) %>
  <th>ID</th>
<% end %>

<td>
  <%= user.id %>
</td>

<% content_for(:table_footer) %>
  <th>ID</th>
<% end %>

With all of our partials using this same format; as they get rendered down the list, they also append their content to the respective content_for.

But there is one gotcha with this! We have more than one user to display in our table, but we don’t want to be rendering out new header and footer columns every time we render a different user. Thankfully, there is a simple workaround we can leverage. By tracking the index of our iteration, we can only render the header & footer content if we’re working on the first record.

<% content_for(:table_body) do %>
  <% users.each_with_index do |user, index| %>
    <% render_columns = index == 0 %>
    <tr>
      <%= render "user_table/id", user:, render_columns: %>
      <%= render "user_table/name", user:, render_columns: %>
      <%= render "user_table/email", user:, render_columns:  %>
      <%= render "user_table/actions", user:, render_columns:  %>
    </tr>
  <% end %>
<% end %>

<table>
  <thead>
    <tr><%= content_for :table_header %></tr>
  </thead>

  <tbody>
     <%= content_for :table_body %>
  <tbody>

  <tfooter>
    <tr><%= content_for :table_footer %></tr>
  </tfooter>
</table>

And our partials could be updated to move the header & footer blocks to be inside a block that checks if we’re rendering the first record.

<td>
  <%= user.id %>
</td>

<% if render_columns %>
  <% content_for(:table_header) %>
    <th>ID</th>
  <% end %>

  <% content_for(:table_footer) %>
    <th>ID</th>
  <% end %>
<% end %>

With the above in place, we can now easily add, remove, and adjust table cells and their associated header & footer columns! We can also be confident that the headers and footer changes will stay in sync now that they are together in the same file.

Rendering Multiple Tables

When rendering multiple tables out, we may need to clear the table_header so it does not have previous values. We could do something like this at the top of the _table.html.erb

<% content_for :table_header, "", flush: true %>
<% content_for :table_body, "", flush: true %>
<% content_for :table_footer, "", flush: true %>

Another alternative would be to use per-table content_for sections, like users_header or products_body.

Performance Considerations

This approach to table rendering essentially means we are rendering each table cell individually. There is a minor overhead to rendering a partial, and that can impact performance with this approach to table rendering if we have a big table or many tables to render on a page within one request.

To address this, we can utilize fragment caching to render the rows once and then read from the cache on subsequent renders, only re-rendering when a row has changed.

This is part of the tradeoff we make by refactoring our code to be more maintainable - it often means making other tradeoffs in performance and sometimes supporting optimizations.

Summary

We’ve refactored our table to be more adaptive to future changes. In the process, we had to give up some of the simplicity of it all being in one file and using a simple rendering approach.

Often when tables become difficult to manage, the only alternative we have as developers is to rely on automated table generation (such as converting a Hash to a table, or a frontend library that reads in JSON) to do it for us. But here we explored a way to exercise more concise control over the table columns rendering by making a few simple changes to how we render the HTML table, while still keeping the underlying HTML available to us for changes and customizations.