Easy empty states for Phoenix LiveView streams using Tailwind

Phoenix on Rails is a comprehensive guide to Elixir, Phoenix and LiveView for developers who already know Ruby on Rails. If you’re a Rails developer who wants to learn Phoenix fast, click here to learn more!

Phoenix 1.7 introduced streams, a way to render large collections in LiveView without needing to keep the entire collection in memory on the server. They’re a useful tool, but they have some quirks that take getting used to, for example if we want to render a special “empty state” when the collection is empty.

If our collection was a simple Elixir list and not a stream, we could wrap it in a simple if statement using something like Enum.any?, with our empty state in the else clause. But this doesn’t work with streams. Streams use their own special data structure that won’t work with the Enum module; the only thing you can do to a stream is iterate over it using for. So there’s no straightforward way to apply conditional behavior to a stream using Elixir constructs like if.

Fortunately, our Elixir code isn’t the only level at which we can conditionally show or hide an element. It’s also possible using CSS pseudo-classes - and with a quick little Tailwind trick, we can get the behavior we want in a really neat and succinct way.

See the video for more:

(Credit to Mike Clark of the Pragmatic Studio, from whom I originally learned a version of this trick.)

Mentioned in this video:

Code

The code used in this video was initially generated using mix phx.gen.live Todos Task tasks title:string. The only change I’ve made to the generated module TaskLive.Index was to remove the clause from apply_action/3 that handled the case when socket.assigns.live_action == :edit, as it’s not relevant to the point I’m trying to illustrate.

 # lib/arrowsmith/live/task_live/index.ex
defmodule ArrowsmithWeb.TaskLive.Index do
  use ArrowsmithWeb, :live_view

  alias Arrowsmith.Todos
  alias Arrowsmith.Todos.Task

  @impl true
  def mount(_params, _session, socket) do
    {:ok, stream(socket, :tasks, Todos.list_tasks())}
  end

  @impl true
  def handle_params(params, _url, socket) do
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end
-
- defp apply_action(socket, :edit, %{"id" => id}) do
-   socket
-   |> assign(:page_title, "Edit Task")
-   |> assign(:task, Todos.get_task!(id))
- end

  defp apply_action(socket, :new, _params) do
    socket
    |> assign(:page_title, "New Task")
    |> assign(:task, %Task{})
  end

  defp apply_action(socket, :index, _params) do
    socket
    |> assign(:page_title, "Listing Tasks")
    |> assign(:task, nil)
  end

  @impl true
  def handle_info({ArrowsmithWeb.TaskLive.FormComponent, {:saved, task}}, socket) do
    {:noreply, stream_insert(socket, :tasks, task)}
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    task = Todos.get_task!(id)
    {:ok, _} = Todos.delete_task(task)

    {:noreply, stream_delete(socket, :tasks, task)}
  end
end

I changed the index.html.heex template substantially from the generated version. It initially looks like this:

<.header class="mb-4">
  All tasks
  <:actions>
    <.link class="text-blue-600 hover:text-blue-800" patch={~p"/tasks/new"}>
      New Task
    </.link>
  </:actions>
</.header>

<div id="tasks" phx-update="stream" class="flex flex-col divide-y">
  <div :for={{dom_id, task} <- @streams.tasks} id={dom_id} class="flex py-4">
    <div class="grow"><%= task.title %></div>
    <.link
      class="text-blue-600 hover:text-blue-800"
      phx-click={JS.push("delete", value: %{id: task.id})}
      data-confirm="Are you sure?"
    >
      Done
    </.link>
  </div>
</div>

<.modal :if={@live_action in [:new, :edit]} id="task-modal" show on_cancel={JS.patch(~p"/tasks")}>
  <.live_component
    module={ArrowsmithWeb.TaskLive.FormComponent}
    id={@task.id || :new}
    title={@page_title}
    action={@live_action}
    task={@task}
    patch={~p"/tasks"}
  />
</.modal>

To add an empty state that’s conditionally shown or hidden by CSS, add it as a child of the stream’s wrapper element with Tailwind classes "hidden" and "only:block":

 

 <div id="tasks" phx-update="stream" class="flex flex-col divide-y">
+  <div id="tasks-empty-state" class="hidden only:block">
+    All tasks complete!
+    <.link class="text-blue-600 hover:text-blue-800" patch={~p"/tasks/new"}>
+      Click here to add a new task
+    </.link>
+  </div>
+ 
   <div :for={{dom_id, task} <- @streams.tasks} id={dom_id} class="flex py-4">
     <div class="grow"><%= task.title %></div>
     <.link
       class="text-blue-600 hover:text-blue-800"
       phx-click={JS.push("delete", value: %{id: task.id})}
       data-confirm="Are you sure?"
     >
       Done
     </.link>
   </div>
 </div>

 

Remember that the empty state must have a DOM ID (in this case "tasks-empty-state") or LiveView won’t be able to update it correctly.

Learn Phoenix fast

Phoenix on Rails is a 72-lesson tutorial on web development with Elixir, Phoenix and LiveView, for programmers who already know Ruby on Rails.

Get part 1 for free: