If you’ve ever written a Rails partial or a ViewComponent that accepts HTML attributes, while at the same time also defining its own HTML attributes, you’ve probably been annoyed by the fact that you have to merge the attributes manually, which is ugly and noisy.
Problem
Here’s a semi contrived example. Say you have a partial for rendering a modal, which uses stimulus:
<div
class="modal"
data-controller="modal"
data-action="showModal->modal#show hideModal->modal#hide"
>
<button class="modal-close" data-action="modal#close">Close</button>
<div class="modal-body"><%= yield %></div>
</div>
But, now you also want to allow consumers of the partial to pass custom data attributes that are set on the container (for example, to add more stimulus controllers or custom actions). Or maybe you want to allow them to add a custom class on the container.
One way to do that would be to accept an attributes
local, and use it like:
<div
class="modal <%= attributes.delete(:class) %>"
data-controller="modal <%= attributes[:data]&.delete(:controller) %>"
data-action="showModal->modal#show hideModal->modal#hide <%= attributes[:data]&.delete(:action) %>"
<%= tag.attributes(attributes) %>
>
<button class="modal-close" data-action="modal#close">Close</button>
<div class="modal-body"><%= yield %></div>
</div>
Or you may convert everything to a hash and use content_tag
:
<%= content_tag(:div,
class: "modal #{attributes.delete(:class)}".strip,
data: {
controller: "modal #{attributes[:data]&.delete(:controller)}".strip,
action: "showModal->modal#show hideModal->modal#hide #{attributes[:data]&.delete(:action)}".strip
}.merge(attributes.delete(:data) || {}),
**attributes
) do %>
<button class="modal-close" data-action="modal#close">Close</button>
<div class="modal-body"><%= yield %></div>
<% end %>
Now imagine you have more things you need to merge more attributes like stimulus targets/values/params, etc.
And you’re doing this a bunch of times.
It gets very noisy, very fast. And I hate it. It’s also error prone.
A better way™️
So, I wrote a simple helper to address that issue. It’s available as a gem called html_attrs.
You install it using bundle add html_attrs
. And you’re ready to go.
Check the GitHub repository for more detailed documentation + examples.
Here’s how things would look if you use the gem:
<%= content_tag(:div, **{
class: 'modal',
data: {
controller: 'modal',
action: 'showModal->modal#show hideModal->modal#hide'
}
}.as_html_attrs.smart_merge(attributes)
) do %>
<button class="modal-close" data-action="modal#close">Close</button>
<div class="modal-body"><%= yield %></div>
<% end %>
Much simpler & cleaner. And it doesn’t matter how many attributes you have to merge. You can read about how it works and other ways to merge things in the README.
It also handles things we didn’t even handle above (e.g if someone passes attributes with string keys instead of symbols).
Coming soon: The only Rails UI library you'll ever need
- Includes a lot of components necessary to build a modern app
- Dark mode support out of the box · Simple to customize & extend
- Simple primitives as well as complex components - not "another UI library"
- Patterns I've found incredibly useful over the past years working with Rails
Subscribe now to receive updates and a free preview
Is there a non-gem way to do this?
If you’d rather not use the gem, you can also just copy this helper into your Rails app, in a view helper or any other place you want to use it:
def smart_merge(other, target)
other = other.with_indifferent_access if other.is_a?(Hash) && !other.is_a?(HashWithIndifferentAccess)
target = target.with_indifferent_access if target.is_a?(Hash) && !target.is_a?(HashWithIndifferentAccess)
return other if target.nil?
return target if other.nil?
if target.is_a?(Hash) || other.is_a?(Hash)
raise 'Expected target to be a hash or nil' if !target.nil? && !target.is_a?(Hash)
raise 'Expected other to be a hash or nil' if !other.nil? && !other.is_a?(Hash)
target.each do |key, value|
other[key] =
if other.key?(key)
smart_merge(other[key], value)
else
value
end
end
return other
end
if target.is_a?(Array) || other.is_a?(Array)
raise 'Expected target to be an array or nil' if !target.nil? && !target.is_a?(Array)
raise 'Expected other to be an array or nil' if !other.nil? && !other.is_a?(Array)
return (other || []).concat(target || [])
end
if target.is_a?(String) || other.is_a?(String)
raise 'Expected target to be a string or nil' if !target.nil? && !target.is_a?(String)
raise 'Expected other to be a string or nil' if !other.nil? && !other.is_a?(String)
return [other.presence, target.presence].compact.join(' ')
end
target
end
You can then use it like this:
<%= content_tag(:div, **smart_merge({
class: 'modal',
data: {
controller: 'modal',
action: 'showModal->modal#show hideModal->modal#hide'
}
}, attributes)
) do %>
<button class="modal-close" data-action="modal#close">Close</button>
<div class="modal-body"><%= yield %></div>
<% end %>
This is the crux of the gem. However, it has been simplified a bit.
The gem also includes ability to specify which attributes are mergeable (with a sane default).
It also includes a few other things like a simple extension that allows you to call as_html_attrs
on any hash to convert it to a smart mergeable hash, that you can then call smart_merge
on, etc.
Get more articles like this
Subscribe to my newsletter to get my latest blog posts, and other freebies like Treact, as soon as they get published !