Learn Phoenix LiveView is the comprehensive tutorial that teaches you everything you need to build a complex, realistic, fully-featured web app with Phoenix LiveView. 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:
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.
No spam. Unsubscribe any time.