As I work more with Hotwire, replacing small chunks of the UI instead of re/rendering the whole page, I start to lose some of the benefits I used to have. Empty states are one of such things you don't get if you change refresh actions for more discrete ones like append or remove.
Take a look at the following example. I have a list of items I want to display, and I want to show an empty state when there're no items.
This behavior is easy to achieve with regular Rails, but adding Turbo Frame features like removing or adding to the main frame loses that ability.
The initial render works fine because the whole page gets evaluated, but as Turbo takes control and submits the form we never get our empty state back. Here's the backend code that sends the append or delete messages back to Turbo.
class NodesController < ApplicationController
def create
@node = Node.new node_params
if @node.save
respond_to do |f|
format.turbo_stream do
render turbo_stream: turbo_stream.append(:nodes, @node)
end
format.html { redirect_to nodes_url }
end
else
render :new, status: :unprocessable_entity
end
end
def destroy
@node = Node.find params[:id]
if @node.destroy
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.remove(@node)
end
format.html { redirect_to nodes_url }
end
else
render :index, status: :unprocessable_entity
end
end
end
Update all-the-frames
This problem can be easy to fix, or at least to hack. We could tell the backend to replace the wrapping turbo frame instead of removing/adding stuff to it. Telling Turbo to do this is easy, but it feels a bit out of place, as it starts resembling a lot to Turbolinks, and we don't want that. We want to be very prescriptive about our updates. Here's how you can achieve that.
def create
...
respond_to do |format|
format.turbo_stream do
render turbo_stream:
turbo_stream.replace(:nodes, partial: "nodes/_collection", locals: {nodes: Node.all})
end
end
...
end
def destroy
...
respond_to do |format|
format.turbo_stream do
render turbo_stream:
turbo_stream.replace(:nodes, partial: "nodes/_collection", locals: {nodes: Node.all})
end
end
...
end
CSS solution
Another possible way of achieving the same behavior is with CSS. We can piggyback on the :empty
pseudo-class and show a blank state using the content property. That's ok, but again a little hacky for my taste. Having a CSS rule probably won't cut it for screen readers, and it's also not very semantic.
#nodes:empty:after {
content: "Add new nodes 👇";
text-align: center;
}
#nodes:empty {
min-height: 1rem;
}
<div class="grid grid-flow-row gap-4 w-full">
<!-- Keeping the if/unless because rendering an empty array yields a blank space and breaks the :empty css selector --!>
<%= turbo_frame_tag "nodes", class: "grid grid-flow-row gap-4 w-full" do %>
<% unless nodes.empty? %>
<%= render nodes %>
<% end %>
<% end %>
</div>
JS solution
So far, we haven't tried to build a solution with JS, and I think we need to roll up our sleeves and make one.
A little disclaimer: I'm not a fan of Stimulus. After years of writing reactive UIs with React, using the DOM imperative API feels odd. Most of the Stimulus code I see out there leaves the DOM manipulations up to you to write on instance methods. My experience is that manually updating the DOM on state changes is error-prone; that's why most UI frameworks choose some flavor of reactive programming.
There must be a way to write the last 5% of the features that Hotwire can't handle reactively, and it turns out that there is. Say hi to Alpine.js.
Alpine.js feels a lot like angularjs, but without the mess and probably with more modest ambitions. It uses directives to enhance your HTML.
The syntax is straightforward, and I don't think I can do better than Alpine.js docs explaining it. I'll throw here some snippets assuming the syntax is so easy that you'll understand it without needing to know Alpine.js.
Back to our empty state problem, here's what I want. A piece of data holding whether I need to show the blank state or not and a directive that shows/hides it based on such value.
Now, we only need to know when to recompute this variable. Maybe I can use some Turbo callbacks.
<div class="grid grid-flow-row gap-4 w-full"
x-on:turbo:submit-end="zeroState = $el.querySelectorAll('#nodes turbo-frame').length === 0"
x-data="{
zeroState: <%= nodes.empty? ? 'true' : 'false' %>
}"
>
<%= turbo_frame_tag "nodes", class: "grid grid-flow-row gap-4 w-full" do %>
<%= render nodes %>
<p x-show="zeroState" class="text-center">Add new nodes 👇</p>
<% end %>
</div>
I thought hooking into Turbo events would allow me to recompute the property based on the DOM contents, but I was wrong. Turbo fires a turbo:submit-end
when the form submission ends, but querying the DOM at this point will compute the wrong thing since DOM hasn't been updated yet.
Luckily, we have a standard web API that tells you when a piece of the DOM changes, Mutation Observer. We can use Mutation Observer to call us back when the DOM mutation has been applied, and the zeroState
variable can be recomputed.
<div class="grid grid-flow-row gap-4 w-full"
x-data="{
observer: undefined,
zeroState: <%= nodes.empty? ? 'true' : 'false' %>
}"
x-init="
observer = new MutationObserver(() => {
zeroState = $el.querySelectorAll('#nodes turbo-frame').length === 0
})
observer.observe($el, {
childList: true,
attributes: false,
subtree: true,
})
"
>
<%= turbo_frame_tag "nodes", class: "grid grid-flow-row gap-4 w-full" do %>
<%= render nodes %>
<p x-show="zeroState" class="text-center">Add new nodes 👇</p>
<% end %>
</div>
Perfect! This code nicely does the trick, but wait, it's not very reusable, and empty states will appear everywhere in my app. We could copy and paste this boilerplate around, but we can do a little better by creating a custom directive that avoids us writing all that horrible x-init directive every time.
Another problem with this code is that I haven't found a way to execute cleanup code when the HTML containing the directive goes away. This cleanup is important because otherwise, we will be adding multiple observers and never disconnecting them. Here's what we want our code to look like.
<div class="grid grid-flow-row gap-4 w-full"
x-mutation-observer.child-list.subtree="zeroState = $el.querySelectorAll('#nodes turbo-frame').length === 0"
x-data="{
zeroState: <%= nodes.empty? ? 'true' : 'false' %>
}"
>
<%= turbo_frame_tag "nodes", class: "grid grid-flow-row gap-4 w-full" do %>
<%= render nodes %>
<p x-show="zeroState" x-transition:enter.duration.500ms class="text-center">Add new nodes 👇</p>
<% end %>
</div>
Creating directives in Alpine.js is an advanced topic, more so if you want to mess with the reactive part, we only want to hide some code behind the directive and execute some cleanup tasks when it is unmounted from the DOM, so ours should be easy to build. Here's all the code.
import "@hotwired/turbo-rails"
import "alpinejs"
document.addEventListener('alpine:init', () => {
Alpine.directive("mutation-observer", (el, { expression, modifiers }, { evaluateLater, cleanup }) => {
let callback = evaluateLater(expression)
let observer = new MutationObserver(() => {
callback()
})
observer.observe(el, {
childList: modifiers.includes("child-list"),
attributes: modifiers.includes("attributes"),
subtree: modifiers.includes("subtree"),
})
cleanup(() => {
observer.disconnect()
})
})
})
In our directive, we want to evaluate the expression -the thing that gets passed to the HTML attribute- every time MutationObserver calls us back. For that reason, Alpine.js has an evaluateLater
function that returns a function that evaluates the expression when called.
The modifiers
are what gets sent to the directive after the dot. In our case, those are the params for the MutationObserver config object; this way, we can have a pleasant and expressive API.
Finally, a directive does have a way to clean up via the cleanup
callback. We can use that to disconnect the observer instance.
Aaaand that's it, there's no more work to do. After registering the directive, it will be available as a built-in one. I added some transitions using Alpine.js x-transition
directive to make add some animation for the empty state, and here's how it looks like.
CSS solution v2
Before wrapping up, I want to mention a better way of doing this that was pointed out by Facundo Espinosa, who was kind enough to proofread this post and give me this alternative which I think is better. Yes, better than the wall of code I just made you read; sorry for that 🙏.
CSS has the :only-child
pseudo-class that gets applied when the element is the only child. With that in mind, we could just write something similar to this.
<div class="grid grid-flow-row gap-4 w-full">
<%= turbo_frame_tag "nodes", class: "grid grid-flow-row gap-4 w-full" do %>
<%= render nodes %>
<p class="hidden only:block text-center">Add new nodes 👇</p>
<% end %>
</div>
Still, learning Alpine.js and how to create a custom directive was fun, and I'm sure it will help me in the future.
Top comments (2)
I had no idea there was an
:only-child
CSS selector, so thanks for that.I'm curious about something you wrote with regard to refreshing frames:
As I'm starting to dig into Hotwire, refreshing Turbo Frames does feel like a nice approach, in so much as it feels like a lower cognitive leap when compared to more traditional request-response life-cycles. While it feels a bit more "brute force", I'm not sure that it feels "hacky" (to me). I'm curious to hear some more of your thoughts on the matter.
Could you share the complete source code?