Handling Empty Slots in Phoenix LiveView Components

3 min read

As I was writing a Phoenix LiveView component I determined that I wanted to provide a default "empty state" when a slot provided to the component rendered to a blank value. A good example is a detail list component that may be used as follows:

<.list>
  <:item title="Email"><%= @contact.email %></:item>
  <:item title="Mobile Phone"><%= @contact.mobile_phone %></:item>
</.list>

In the component you would iterate through the item slots and render them.

slot :item, required: true do
  attr :title, :string, required: true
end

def list(assigns) do
  ~H"""
  <dl class="-my-4 divide-y divide-gray-100">
    <div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
      <dt class="w-1/5 flex-none text-gray-500"><%= item.title %></dt>
      <dd class="text-gray-700 flex-1 overflow-hidden">
        <%= render_slot(item) %>
      </dd>
    </div>
  </dl>
  """
end

In this case, if @contact.email is nil, the slot would render nothing. What I want is to let the user know that this value is not set. I could add a check on every value (e.g. <%= @contact.email || "Not set" %>), but what I really want is to render a nice Badge component I use in my application. I also want this behavior to be the default behavior for all list components throughout my application. I believe it therefore makes sense for the component to detect slots with empty inner_block values and handle the default state automatically.

The way I was able to do this was to pre-render the slot, check for "emptiness" and conditionally output the slot or a default label instead.

To do this, the above component could be modified as follows:

slot :item, required: true do
  attr :title, :string, required: true
  attr :default, :string
end

def list(assigns) do
  ~H"""
  <dl class="-my-4 divide-y divide-gray-100">
    <div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
      <dt class="w-1/5 flex-none text-gray-500"><%= item.title %></dt>
      <dd class="text-gray-700 flex-1 overflow-hidden">
        <% slot = render_slot(item) %>
        <%= if slot_empty?(slot) do %>
          <Badges.badge><%= Map.get(item, :default, "Not set") %></WeigandUI.Badges.badge>
        <% else %>
          <%= slot %>
        <% end %>
      </dd>
    </div>
  </dl>
  """
end

# Checks a rendered slot to see if it has rendered to an empty string
# Useful for checking if a slot has a value or if we need to render a default value
defp slot_empty?(%Phoenix.LiveView.Rendered{} = slot) do
  slot
  |> Phoenix.HTML.Safe.to_iodata()
  |> IO.iodata_to_binary()
  |> String.trim()
  |> Kernel.==("")
end

The component now calls render_slot(item) to get a %Phoenix.LiveView.Rendered{} struct, then passes it to slot_empty?/1. The struct is then converted to iodata, then to binary, trimmed to remove whitespace characters, and finally, evaluated to check if the rendered result is an empty string. If the slot renders to an empty string, we then show a default badge with an optional default message set on the slot.

Caveats & potential issues

I do not know the performance implications of this technique using essentially a "double render", but the pipeline in slot_empty?/1 should be fairly lightweight considering the limited usage.

The technique requires that the slot renders to an empty string. If you have a more complex inner_block than a simple value, you will have to ensure the block doesn't render anything by adding a conditional on the slot's root element:

<:item title="Email">
  <.link :if={@contact.email} href={"mailto:#{@contact.email}"}><%= @contact.email %></.link>
</:item>

There also may be a better, less-hacky way to accomplish what is essentially a default slot with LiveView, so if you have any better ideas please let me know.