Phoenix LiveView: Async Assign Pattern
- Set sensible, lightweight default values on initial render.
- Kick off one or more async processes to make longer running function calls.
- Receive the values in the LiveView, and update the assigns.
- 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
The Async Assigns 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:
- Initial call to mount (socket not connected).
- Block the initial render until the `front_page/0` data is returned, and assigned to the socket.
- Second call to mount (socket is connected).
- Spawn an unlinked process that will run `supplier`.
- 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
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 :)
ReplyDeleteYeah, 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.
DeleteHey thanks for this write up an giving a good idea how to async load slow data into LV.
ReplyDeleteThe example in NewsSite.NewsLive with `Task.start(fn ->` won't work since it assigns the return value of Task.start() (e.g. {:ok, %PID{...}}) to the socket and this crashes everything :)