This post is intended to gather feedback on a proposal for a new feature for Rails, while I put together a pull request. I welcome any comments, either here or on X or BlueSky
TL:DR
I want to propose an extension to Ruby on Rails TagHelpers which will enable us to create HTML structures that were cumbersome before.
In short, the view helper code to write this,
<figure>
<img src="/path/to/image.jpg" alt="Alternate Text" />
<figcaption>Figure Caption</figcaption>
</figure>
could look like this (with my extension proposal),
def figure(src, alt:, figcaption: nil)
tag.figure do |b|
b << tag.img(src:, alt:)
b << tag.figcaption(figcaption) if figcaption.present?
end
end
instead of this (how we would currently write this with view helpers),
def figure(src, alt:, figcaption: nil)
figure_content = tag.img(src:, alt:)
figure_content += tag.figcaption(figcaption) if figcaption.present?
tag.figure do
figure_content
end
end
The proposed approach allows us to write more complex HTML with tag helpers alone (without ERb), using code that closely mirrors the HTML structure. This will simplify the creation of UI components.
Note: The HTML and view helper code snippets above come from Garrett Dimon's blog post.
Credit to Garrett Dimon and Adam McCrea
Garrett Dimon wrote an excellent post "Structure Your ERb and Partials for more Maintainable Front-end Code in Rails" where he discusses alternative approaches to using view helpers and partials, together with points to consider when choosing between the two.
Adam McCrea wrote a post in response, "Say No To Partials And Helpers For A Maintainable Rails Front-End", proposing "throwing out ERB partials and helpers altogether, and using Phlex instead".
Although I fully agree that Phlex is fantastic and a breath of fresh air, I hope my proposal addresses at least one of Adam's major complaints and will allow us to positively re-evaluate traditional Rails ERb and view helpers.
In short, I don't want to throw the baby out with the bathwater.
Let's first look at ViewComponents and Phlex
Before diving into the details of what I am proposing, let's first look at how you would write the HTML above using ViewComponents and Phlex.
I hope this illustrates my point, which can be summarised as "Why do we need to write a new class for just 4 lines of HTML?".
I think there is a better way.
ViewComponents
figure_component.rb
class FigureComponent < ViewComponent::Base
def initialize(src, alt:, figcaption: nil)
@src = src
@alt = alt
@figcaption = figcaption
end
end
figure_component.html.erb
<figure>
<img src="<%= @src %>" alt="<%= @alt %>" />
<% if @figcaption.present? %>
<figcaption><%= @figcaption %></figcaption>
<% end %>
</figure>
Phlex
class Components::Figure < Components::Base
def initialize(src, alt:, figcaption: nil)
@src = src
@alt = alt
@figcaption = figcaption
end
def view_template
figure do
img(src: @src, alt: @alt)
figcaption { @figcaption } if @figcaption
end
end
end
This proposal
def figure(src, alt:, figcaption: nil)
tag.figure do |b|
b << tag.img(src:, alt:)
b << tag.figcaption(figcaption) if figcaption.present?
end
end
The need for a simpler approach
Both ViewComponent and Phlex are class based and are great for components that need to encapsulate logic.
However, with the rise of Tailwind CSS, we are encouraged to write a lot of smaller components whose only purpose is to DRY our code and save us from writing a ton of CSS classes – they have very little internal logic. In these cases, ViewComponents and Phlex can be an overkill.
In many cases, traditional view helpers included in Rails are sufficient for this. For the following HTML for example, you can write a very easy-to-understand helper that mirrors the HTML structure like this.
HTML
<figure>
<img src="/path/to/image.jpg" alt="Alternate Text" />
</figure>
view helper
def figure(src, alt:)
tag.figure do
tag.img(src:, alt:)
end
end
However, this breaks down when the <figure>
element has more than one child.
For the following HTML where <figure>
has two children, you could either write a hard-to-read view helper, or more often, you would use ERb.
Although I fully appreciate that ERb is better than view helpers for rendering highly complex HTML, I find it hard to accept the idea that simply increasing the number of children from one to two makes the HTML substantially more complex. I am uncomfortable with the thought that this alone should compel us to use ERb instead of view helpers.
HTML
<figure>
<img src="/path/to/image.jpg" alt="Alternate Text" />
<figcaption>Figure Caption</figcaption>
</figure>
view helper (hard-to-read)
def figure(src, alt:, figcaption: nil)
figure_content = tag.img(src:, alt:)
figure_content += tag.figcaption(figcaption) if figcaption.present?
tag.figure do
figure_content
end
end
ERb (you would often use this instead of the above view helper)
<figure>
<img src="<%= src %>" alt="<%= alt %>" />
<% if figcaption.present? %>
<figcaption><%= figcaption %></figcaption>
<% end %>
</figure>
Given this situation, I propose an idea to make it easier to write HTML elements with more than one child in view helpers, by adding a feature to tag helpers.
Why don't tag helpers work when there is more than one child?
If we write the following code in a view helper, we lose the <img>
tag and only get the <figcaption>
.
This is because the return value of the do
block is used as the content for the <figure>
tag. Since tag.figcaption
is the last expression in the block, this value becomes the content. The tag.img
is lost because its value is not captured anywhere.
view helper
def figure(src, alt:, figcaption: nil)
tag.figure do
tag.img(src:, alt:)
tag.figcaption(figcaption) if figcaption.present?
end
end
HTML
<figure>
<figcaption>Figure Caption</figcaption>
</figure>
Proposed solution
I propose the following syntax. We extend tag helpers to optionally provide a variable (in this case b
for "buffer") to the block. We push the tag.img
and tag.figcaption
to this buffer (b
), which is implemented as an Array. After the tag helper yields the block, it performs a safe_join
on the buffer resulting in HTML that includes both child elements, and will be used as content for the <figure>
tag. This behaviour will only be triggered when the arity of the block is 1 or larger, thereby ensuring backwards compatibility. (The current implementation of tag helpers do not use block variables)
def figure(src, alt:, figcaption: nil)
tag.figure do |b|
b << tag.img(src:, alt:)
b << tag.figcaption(figcaption) if figcaption.present?
end
end
The code that this enables closely aligns with the structure of the HTML and is easy to compare. Even people with minimal understanding of Ruby should be able to understand what is happening.
Benefits
Through this proposed feature, developers who are hesitant to use ViewComponents or Phlex can better benefit from components while sticking to traditional ERb and view helpers.
It also promotes writing components using functions (view helpers), in a way that is easy for non-Ruby developers (i.e. designers) to understand. Note that modern React components are also functions, not classes.
This feature enables us to implement slightly more complex components in view helpers without resorting to ERb templates. Compared to ERb, the benefits are the ability to fit multiple components in a single file, an explicit interface, a simpler API for calling (you don't need to write render "[path to partial]", [hash of locals]
which can become verbose), and performance.
What about unit testing?
One of the commonly mentioned benefits of using ViewComponents or Phlex instead of ERb and view helpers, is the ability to unit test components.
I do not know where this idea originated from, but I do know that the Rails Guides has sections for unit testing ERb partials and for unit testing view helpers.
I have been unit testing ERb partials and HTML-generating view helpers for several years.
Implementation
Although I have been using a variation of this technique for many years, I am proposing to add this to Rails to make it more convenient and to increase awareness of the safe_join
technique.
I am currently preparing an implementation and a pull request. In the meantime, if you have any comments or opinions, I would love to hear it!
Top comments (0)