DEV Community

Gabriel Chertok
Gabriel Chertok

Posted on

Hotwire empty states with Alpine.js

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.

Regular Rails without Turbo


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.

Turbo appending and removing

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
<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>
Enter fullscreen mode Exit fullscreen mode

CSS rule


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>
Enter fullscreen mode Exit fullscreen mode

Turbo hooks

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>

Enter fullscreen mode Exit fullscreen mode

Mutation Observer

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>
Enter fullscreen mode Exit fullscreen mode

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()
    })
  })
})
Enter fullscreen mode Exit fullscreen mode

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.

Complete example


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>

Enter fullscreen mode Exit fullscreen mode

Still, learning Alpine.js and how to create a custom directive was fun, and I'm sure it will help me in the future.

Discussion (0)