Skip to content Interested in a powerful Rails UI library?

Merging HTML attributes with Rails

Published on

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 !