Phoenix LiveView: Async Assign Pattern




I've been using LiveView for about two years now. It's a great framework that makes snappy and responsive pages. One anti-pattern I see fairly often is loading a lot of data in the initial page render.

For the un-initiated, the mount/3 function is called twice.  Once for the initial 'dead' render, and again after the socket is connected.   Many times, for the sake of simple straight forward code, not much is done differently between these two renders.

I haven't found any references (I'm sure they exist) on a best practice for managing the following flow:
  1. Set sensible, lightweight default values on initial render.
  2. Kick off one or more async processes to make longer running function calls.
  3. Receive the values in the LiveView, and update the assigns.
As always in Elixir, the tools are powerful, and theres many ways to accomplish this.
  • Spawn a linked, or unlinked process.
  • Start a supervised, or unsupervised Task.
  • Make a call to a GenServer, and have it send a message back.
  • Publish to a PubSub topic, and listen for a response.

Example, setting a load state, and then loading the actual data after socket is connected.


defmodule NewsSite.NewsLive do
    
    def mount(_params, _session, socket) do
       
       socket = if !is_connected?(socket) do
       	assign(socket, :front_page, :loading)
       else
       	assign(socket, :front_page, News.front_page())
       end
       
       {:ok, socket}
    end
    
end    

Example, loading fast data on initial render, then slower calls async.


defmodule NewsSite.NewsLive do
    
    def mount(_params, _session, socket) do
       
       socket = if !is_connected?(socket) do
       	assign(socket, :front_page, :loading)
       else
       	pid = self()
       	Task.start(fn ->
        	send(pid, {:front_page, News.front_page())
        end)
       end
       
       {:ok, socket}
    end
    
    @impl true
    def handle_info({:front_page, news_items}, socket) do
      {:noreply, assign(socket, :front_page, news_items)}
    end
    
end    
Each of these approaches, have their appropriate use case.  For the majority of the cases, in most apps, spawning a child process will suffice.

The problem arises when you move past one async assign.   Your mount function will start to get pretty messy, and you'll start breaking things out into private functions.  You also must try to stick to some sort of pattern across your LiveViews, otherwise each LiveView will work a bit differently.

So, how did I solve this problem?

The Async Assigns Module

To solve this issue, I've encapsulated this basic pattern into an AsyncAssigns module.   

Functionality

  • Allows you to set default data in the initial render.
  • Spawn a linked, or unlinked process that will do what is needed to fetch data.
  • Send a message back to the parent LiveView, and assign the values.

The Benefits

  • Provides a consistent pattern for asynchronously loading assigns.
  • Provides a consistent pattern for setting defaults.
  • Allows assigns that must be loaded together to stay together.  Those that are not dependent can be loaded in parallel.


As an example, let's take a look at a News site. Let's assume there is a News module.


defmodule News do
    
    @spec front_page() :: [News.t()] :: {:error, any()}
    def front_page() do
    	# Fast loading news that everyone gets.
    end
    
    @spec news_for_me(user :: User.t()) :: [News.t()] :: {:error, any()}
    def news_for_me(user) do
    	# Slower loading news based on my preferences.
    end
    
end    

The `mount/3` function for this site might look like this. We are assuming the user is already authenticated, and is in `assigns.user`.  This code will:

  1. Initial call to mount (socket not connected).
    1. Block the initial render until the `front_page/0` data is returned, and assigned to the socket.
  2. Second call to mount (socket is connected).
    1. Spawn an unlinked process that will run `supplier`.
    2. When `news_for_me` returns, the spawned process will send a message back to the LiveView, and it will be assigned to key.


defmodule NewsSite.NewsLive do
    
    def mount(_params, _session, socket) do
       
       socket = async_assign(
       	socket,
        key: :front_page,
        default: News.front_page(),
        supplier: fn socket ->
        	News.news_for_me(socket.assigns.user)
        end
       )
       
       {:ok, socket}
    end
    
end    

Again, this is a more trivial example, and you may be wondering why we'd bother doing this, and not just check if the socket is connected in `mount/3`, and load custom news there. The pattern becomes much more useful the more things you are loading. Let's expand on the first example. Assume that the code above has been moved to `async_load_front_page/1`.


defmodule NewsSite.NewsLive do
    
    def mount(_params, _session, socket) do
       
       socket = socket
       |> async_load_front_page()
       |> async_load_friends()
       |> async_load_suggested_articles()
       |> async_load_notifications()
       
       {:ok, socket}
    end
    
    # ...
    
    defp async_load_notifications(socket) do
      async_assign(
        socket,
        default: [notif_count: :loading, notif_unread: :loading],
        on_error: [notif_count: :error, notif_unread: :error],
        supplier: fn socket ->
            %{unread: unread, count: count} = Notifications.unread(socket.assigns.user)
            [notif_count: count, notif_unread: unread]
        end
      )
    end
    
end    

Using this pattern, it is much cleaner and faster to implement many things that should load concurrently. In addition, you can see a slightly different use case in the `async_load_notifications/1` example. The `:notif_count`, and `:notif_unread` assigns should be set at the same time. If `key` is not provided, then you can return any keyword list of assigns. This helps ensure that assigns that are tied together are always set together. Also, in this setup, we are giving the caller of `async_load_notifications` a choice on defaults. If not specified, both `:notif_count` and `:notif_unread` will be set to loading. If they only want to check for new notifications, only the count will be set to loading. This way, the rendered list of notifications will not go away, will still be updated when the results are returned. This is a contrived example, so don't pick it apart too much. It's just intended to illustrate the flexibility of the pattern.


Another benefit of structuring your data loading like this, is that is very easy to reload values.  Below, you can see two events, one reloads notifications, and one only checks for new notifications.  This helps us manage assigns that should be modified together in one spot.


defmodule NewsSite.NewsLive do
    
    @impl true
    def handle_event("reload_notifications", _unsigned_params, socket) do
      {:noreply, async_load_notifications(socket)}
    end
    
    @impl true
    def handle_event("check_for_new_notifications", _unsigned_params, socket) do
      {:noreply, async_load_notifications(socket, [notif_count: :loading])}
    end
    
    # ...
    
    defp async_load_notifications(socket, default \\ [notif_count: :loading, notif_unread: :loading]) do
      async_assign(
        socket,
        default: default,
        on_error: [notif_count: :error, notif_unread: :error],
        supplier: fn socket ->
            %{unread: unread, count: count} = if Notifications.unread(socket.assigns.user)
            [notif_count: count, notif_unread: unread]
        end
      )
    end
    
end    

If you'd like to use this code, feel free to copy and reuse from the gist below. I feel it is premature to turn this into a library, but maybe if I evolve it, and test it enough, I will publish it to hex.

Usage



# Add the following to you Web.live_view/1 function so it's available to all LiveViews,
# or add directly to any Live you want to try it in.

use AsyncAssigns
import AsyncAssigns


Update!

This post received a good amount of views, and people have pointed out that it is a much better option to kick off these async processes with Task.  I actually was using this, but thought I'd experiment with using spwan / spawn_link directly.  I'll have to experiment with how these approaches differ, especially in error / crash situations.  If you plan to use the code below, you may want to swap out the spawns for Task calls!  (Thanks for calling this out Jason!

Also, I'm using live view version 17.x, and some of the functions have moved around for newer versions.  You may need to change where assigns, or is_connected? are being imported from.  Thanks for reading!  - Andy

Comments

  1. It sounds like a good idea, in principle. The only issue you might run into is loading way too many things from the DB at the same time, which could max out your connection pool and make parts of your page not work as intended sometimes. But i do like the pattern and use a similar approach most of the times as well. Thanks for sharing :)

    ReplyDelete
    Replies
    1. Yeah, that is definitely a concern. I'll have to do some thinking on good ways to mitigate connection pool exhaustion, or add in some sort of sensible retry mechanism.

      Delete

Post a Comment

Popular posts from this blog

Write Admin Tools From Day One

Custom Kaffy Styling