Tutorial
Create ghost loading cards in Phoenix LiveView
This post was updated 27 Mar
Unless you already didn’t know, when a LiveView component is mounted on a page, it runs the mount/3 function twice. Once when the page is rendered from the initial request, and once when the socket is connected. So if you have data that is slow or resource heavy to load, you should consider waiting until the socket is connected so the initial load is not in vain.
A popular approach is to use ghost cards. A ghost card is a loading placeholder that looks similar to the content that you try to load in but without actual data.
And you can test this by inserting a sleep in your code. For example a 3 seconds sleep is :timer.sleep(3000)
So, instead of a blank loading screen, in my example it would look something like this:
As it turns out, this is (of course) quite simple to do with Phoenix LiveView.
STEP 1 - Update LiveView component
I already have my products list in a LiveView component. In this case it’s mounted from the routes file and I actually load the data in the handle_params/3 function. The function I call is get_and_assign_page/1. I need to change that to take a second argument: whether we are connected or not.
# lib/my_app_web/live/product_list_live.ex
def handle_params(_, _, socket) do
connected = connected?(socket)
assigns = get_and_assign_page(nil, connected)
{:noreply, assign(socket, assigns)}
end
Then I change get_and_assign_page/1 to take two arguments like:
# lib/my_app_web/live/product_list_live.ex
def get_and_assign_page(_page_number, false) do
total_count = Repo.aggregate(Product, :count)
product_count = Enum.min([10, total_count])
[
products: Enum.to_list(1..product_count)
]
end
def get_and_assign_page(page_number, _) do
%{
entries: entries,
page_number: page_number,
page_size: page_size,
total_entries: total_entries,
total_pages: total_pages
} = Products.paginate_products(page: page_number)
[
products: entries,
page_number: page_number,
page_size: page_size,
total_entries: total_entries,
total_pages: total_pages
]
end
NOTE In the first one, I just want to know how many ghost lines I want to display. And since there is pagination on the page, I want to show a maximum of 10. If there are less than 10 products in the database, I want to show the correct amount of ghost lines. And to know that, I just count the records in the database with Repo.aggregate(Product, :count)
STEP 2 - Update the template
I think the easiest way (for me) is to solve it by rendering a different template. You might of course solve it otherwise.
So still in my LiveView component:
# lib/my_app_web/live/product_list_live.ex
def render(assigns) do
if connected?(assigns.conn) do
MyAppWeb.ProductView.render("products.html", assigns)
else
MyAppWeb.ProductView.render("products_loading.html", assigns)
end
end
and then add the new loading template products_loading.html.heex:
<div class="card mb-20">
<div class="card-header flex">
<h5 class="mb-0 flex-1">Listing Products</h5>
<span class="text-sm">
<.link navigate={~p"/products/new"} class="text-white hover:text-gray-200">New Product</.link>
</span>
</div>
<table class="card-body table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Price</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for _ <- @products do %>
<tr>
<td><div class="h-6 bg-gray-200"></div></td>
<td><div class="h-6 bg-gray-200"></div></td>
<td><div class="h-6 bg-gray-200"></div></td>
<td class="w-1/12 whitespace-no-wrap"><div class="h-6 bg-gray-200"></div></td>
</tr>
<% end %>
</tbody>
</table>
</div>
RESULT
This would without any JavaScript add a loading ghost card with pure LiveView. First it will display the loading card and then the actual list. It would switch from:
to: