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.
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).
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.
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.
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. 🤷♂️