justin․searls․co

Abusing Rails' content_for to push data attributes up the DOM

(I just thought of this today, and it's probably a terrible idea, but it seems to work. If you have reason to believe this is really stupid, please let me know!)

If you use Hotwired with Rails, you probably find yourself writing a lot of data attributes. This is great, because (similar to HTMX), it makes the DOM the primary source of truth and all that, but it sometimes imposes one of several vexing constraints on Stimulus components:

  • Stimulus values must be set on the same element that specifies the controller (i.e. whatever tag has data-controller="cheese" must contain all its value attributes, like data-cheese-smell-value="stinky"). This can be a problem when you'll only have easy access to the value at some later point in your ERB template. You can't just set it on a descendant
  • A Stimulus controller's targets (as in, data-cheese-target="swiss") must be descendants of the controller, which can present design challenges when those targets appear in wildly different areas of the DOM, rendered by unrelated templates
  • Actions will only reach a controller if that controller is an ancestor of whatever node triggered the event (i.e. data-action="click->cheese#sniff" only works if it's placed on a descendant of the element with data-controller="cheese")

I often find myself writing Stimulus components that would be easier to implement if any of the above three things weren't true, and it can occasionally lead me to wishing I could just chuck a data attribute near the top of the DOM in my layout from an individual view or partial to ensure every element involved shares a certain controller as a common ancestor. The alternatives are all worse: storing one-off data attributes as values (which don't benefit from Stimulus nifty Values API), binding actions to global events (@window), or indirect inter-controller communication.

An example problem

In my particular case, I have a bit of UI in my layout that resembles an iOS navigation bar. That bar will render a search field for certain views that have a number of elements that should be filterable by that search bar. The DOM tree looks like this:

  • A top-level layout template:
    • A navigation bar partial (that allows customization via yield :navbar)
    • A view container containing the layout's primary yield to each view. Each view, in turn:
      • Renders whatever content they need
      • [Optional] Configures whether the navigation bar renders a search field for that page (via content_for :navbar)
      • [Optional] Renders a list of elements that should be filterable

Say this filtering behavior is governed by a Stimulus controller called Filterable. This setup raises the question: where should the data-controller="filterable" attribute go? It can't go in the navigation bar, because then the target elements to be filtered would not descend from the controller. It can't go in the view, because then the search bar's events wouldn't trigger actions on the controller. Of course, it could go on the layout's <body> tag, but what if only a handful of pages offer filter functionality? Binding every possible Stimulus controller to the body of every single page is obviously the wrong answer.

My solution? Abuse the hell out of Action View's content_for and yield methods. (Here are the relevant Rails guides if you're not familiar).

My hacked-up solution

In short, I just encode my desired data attributes from views and partials as JSON in a call to content_for and then render them upstream to the layout with yield as attributes on the <body> (or some other common ancestor element).

In this very simple case, the only thing I needed to push up the DOM to a shared ancestor was a data-controller="filterable", which just required this bit of magic at the top of the view containing my filterable items:

<% json_content_for :global_data_attrs, {controller: "filterable"} %>

And this update to my layout:

<%= content_tag :body, data: json_content_from(yield(:global_data_attrs)) do %>
  <!-- Everything goes here -->
<% end %>

And… boom! The page's body tag now contains data-controller="filterable", which means:

  • The items in the view (each with data-filterable-target="item" set) are now valid targets
  • The search bar's actions (with data-action="input->filterable#update") are now reaching the body's Filterable controller.

How it works

Here's how I implemented the helper methods json_content_for and json_content_from to facilitate this:

# app/helpers/content_for_abuse_helper.rb
module ContentForAbuseHelper
  STUPID_SEPARATOR = "|::|::|"

  def json_content_for(name, json)
    content_for name, json.to_json.html_safe + STUPID_SEPARATOR
  end

  def json_content_from(yielded_content)
    yielded_content.split(STUPID_SEPARATOR).reduce({}) { |memo, json|
      memo.merge(JSON.parse(json)) { |key, val_1, val_2|
        token_list(val_1, val_2)
      }
    }
  end
end

Take particular note of the call to token_list there. Because it's being called in a block passed to Hash#merge, that means any duplicate data attribute names will have their contents concatenated with whitespace between them.

This way you could have one view or partial contain:

<% json_content_for :global_data_attrs, {controller: "cheese"} %>

And another with:

<% json_content_for :global_data_attrs, {controller: "meats veggies"} %>

And a layout like the one above will ensure all of your controllers come along for the ride:

<body data-controller="cheese meats veggies">

Cool.

This is a hack

The content_for and yield methods were invented in a simpler time when developers had the basic decency to keep HTML in their HTML files, CSS in their CSS files, and JavaScript in their JavaScript files. But thanks to Tailwind and Stimulus, I find I'm writing more and more of my styling and behavior in HTML attributes, which is why I contorted these two methods to do what I wanted.

I'd advise anyone tread lightly with approaches like this. Any time you muck with a higher-order thing from a lower-order thing, you're creating an opportunity for confusion, making caching more difficult, and opening the door to conflicts between unrelated pieces of code.

But, I dunno. Seems to work. 🤷‍♂️


Got a taste for hot, fresh takes?

Then you're in luck, because you can subscribe to this site via RSS or Mastodon! And if that ain't enough, then sign up for my newsletter and I'll send you a usually-pretty-good essay once a month. I also have a solo podcast, because of course I do.