We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
How to Use LiveView's Async Assigns
Asynchronous data loading with LiveView just got a whole lot easier with the release of LiveView 0.20.0.
Don’t want to wait? Check out a working example repo here (Just run mix.setup
!)
Meet your new friends assign_async
and async_result
:
assign_async
is the new and improved way to handle asynchronous data loading in LiveViews. Let’s take a look at how it works and how you can use it.
We’ll also take a peek at the new AsyncResult
struct and handle_async/3
callbacks to show how you can take lower level control of async operations if need be.
assign_async
The assign_async/3
function takes a name, a list of keys which will be assigned asynchronously, and a function that returns the result of the async operation. It handles the loading for you without needing to juggle handle_info
callbacks or trapping exits.
It’s important to note that, under the hood, assign_async/3
will only run the async task if the socket is connected.
Load a single assign
The simplest example is assigning one value to the socket’s assigns:
def mount(_params, _session, socket) do
{:ok,
socket
|> assign_async(:admin_user, fn ->
{:ok, %{admin_user: Accounts.get_user_by_email("john@johnelmlabs.com")}}
end)}
end
Here we’re loading a user from the database, assigning it to the socket with key :admin_user
and passing a function that fetches the user from the Db.
The function that it takes should return either an {:ok, assigns}
or {:error, reason}
tuple.
assigns
can be either a keyword list or a map and reason
can be either an atom or a string.
Loading multiple assigns
If we want to load multiple assigns we can pass a list of keys as the second argument. The function we pass then needs to return a map or keyword list with the specified keys, like so:
def mount(_params, _session, socket) do
{:ok,
socket
|> assign_async([:john, :tim], fn ->
{:ok,
%{
john: Accounts.get_user_by_email("john@johnelmlabs.com"),
tim: Accounts.get_user_by_email("tim@johnelmlabs.com")
}}
end)
end
In this example we assign both :john
and :tim
to the socket. Our function returns an {:ok, assigns}
tuple with both the john
and tim
keys specified in the map.
Loading collections
If we need more than one or two we can load entire collections with assign_async
as well. Check it out:
def mount(_params, _session, socket) do
{:ok,
socket
|> assign_async(:all_users, fn -> {:ok, %{all_users: Accounts.list_users()}} end)}
end
Here we’re loading all users from the database in much the same way as before. With assign_async
loading async data becomes trivial.
So, how do we actually display this data?
Displaying async data
Under the hood, assign_async
uses AsyncResult
structs. The struct is assigned to the socket right away and we can check the status of the assign with the four fields of the struct:
-
:ok?
is a boolean that determines if the result succeeded -
:loading
is truthy when the assign is loading -
:result
is set to the successful result when the async operation completes -
:failed
is set to the failure reason when the async operation fails
Let’s take a look at how it works. Let’s load a single user from our db:
def mount(_params, _session, socket) do
{:ok,
socket
|> assign_async(:admin_user, fn ->
{:ok, %{admin_user: Accounts.get_user_by_email("john@johnelmlabs.com")}}
end)
end
On the rendering side of things we can display a loading message while waiting for our user to load:
<div :if={@admin_user.loading}>Loading admin ...</div>
Once the user has loaded, we can display it:
<div :if={admin_user = @admin_user.ok? && @admin_user.result}><%= admin_user.email %></div>
We can pattern match the admin user assign after checking the result is ok
and the admin_user
assign was set to the :result
field of the AsyncResult
struct.
Now it would be a little annoying to litter the :if
statements all over the template and that’s exactly what the async_result
helper was designed to help alleviate:
async_result
async_result
is a new function component that is now part of Phoenix.Component
. It has one required assign, aptly named assign
, that it renders. It has three slots:
-
:loading
is shown while the assign is being fetched -
:failed
is shown if the async assign function failed to load -
:inner_block
, the standard function component slot, is shown after the assign loads successfully.
Let’s take a look at how to use it:
def mount(_params, _session, socket) do
{:ok,
socket
|> assign_async([:john, :tim], fn ->
{:ok,
%{
john: Accounts.get_user_by_email("john@johnelmlabs.com"),
tim: Accounts.get_user_by_email("tim@johnelmlabs.com")
}}
end)
end
def render(assigns) do
~H"""
<.async_result :let={user} assign={@tim}>
<:loading>Loading Tim...</:loading>
<:failed :let={reason}><%= reason %></:failed>
<span :if={user}><%= user.email %></span>
</.async_result>
"""
end
While the assign is loading, we show “Loading Tim…”
Once the assign is complete, the inner block is shown which is the user’s email address
If the assign fails to load, the :failed
slot is displayed.
Under the Hood
Sometimes we need to take lower level control of the async operations. In these scenarios we can work with AsyncResult
structs, start_async/3
and handle_async/3
directly.
Let’s take a look at the example from the LiveView docs:
def mount(%{"id" => id}, _, socket) do
{:ok,
socket
|> assign(:org, AsyncResult.loading())
|> start_async(:my_task, fn -> fetch_org!(id) end)}
end
def handle_async(:my_task, {:ok, fetched_org}, socket) do
%{org: org} = socket.assigns
{:noreply, assign(socket, :org, AsyncResult.ok(org, fetched_org))}
end
def handle_async(:my_task, {:exit, reason}, socket) do
%{org: org} = socket.assigns
{:noreply, assign(socket, :org, AsyncResult.failed(org, {:exit, reason}))}
end
There’s a fair amount of new stuff here so let’s break it down:
assign(socket, :org, AsyncResult.loading())
In order to avoid a “missing key” error we assign :org
synchronously. AsyncResult.loading()
is a simple function that just returns a new struct with loading
set to true
:
def loading do
%AsyncResult{loading: true}
end
start_async/3
kicks off the task. Like assign_async/3
, start_async/3
will only run the task if the socket is connected:
start_async(socket, :my_task, fn -> fetch_org!(id) end)}
Once the task is completed (or exits), the handle_async/3
callbacks are invoked with the result of the async operation:
def handle_async(:my_task, {:ok, fetched_org}, socket) do
%{org: org} = socket.assigns
{:noreply, assign(socket, :org, AsyncResult.ok(org, fetched_org))}
end
def handle_async(:my_task, {:exit, reason}, socket) do
%{org: org} = socket.assigns
{:noreply, assign(socket, :org, AsyncResult.failed(org, {:exit, reason}))}
end
If the result succeeds, we assign it to the socket with AsyncResult.ok/2
.
If it fails, we assign it to the socket with AsyncResult.failed/2
Inside the callbacks we can take additional steps such as logging, error recovery, or other side effects.
What does this mean?
assign_async/3
just made writing LiveViews and HEEx easier than ever.
Due to its implementation, we don’t even need to worry if the socket is connected or not anymore (the function won’t run if disconnected). I would argue it makes sense to default to using assign_async/3
versus checking connceted?(socket)
and then assigning. The async_result/3
helper makes it trivial to show a loading state and massively improves UX - We can show a meaningful first paint to the user right away and data slots into the page when it’s ready.
LiveView rules.
Build an AI Powered Instagram Clone with LiveView is on sale now!