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, likedata-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 withdata-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
- A navigation bar partial (that allows customization via
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'sFilterable
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. 🤷♂️