Don't want to miss out on Elixir news? Subscribe to ElixirPulse!

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.