http://blog.andyglassman.com/2023/06/phoenix-liveview-async-assign-pattern.html Skip to main content Milwaukee Maven Hi, I'm Andy Glassman. I post all sorts of things, from thoughts on programming, to reviews of trash tv shows. https://www.linkedin.com/ in/andyglassman/ Phoenix LiveView: Async Assign Pattern * Get link * Facebook * Twitter * Pinterest * Email * Other Apps June 14, 2023 [trains] 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 * Get link * Facebook * Twitter * Pinterest * Email * Other Apps Comments Post a Comment Popular posts from this blog Write Admin Tools From Day One August 22, 2022 Image The Problem Writing useful features for your users is key to a successful product. It makes sense then that you should maximize your time writing features for those users. This approach is very effective at the beginning of a product's life, but over time, you may find you are spending more time maintaining the product that developing it. Some of the biggest development time sucks can be: Tracking down the cause of unexpected behavior. Fixing data. Answering user, or team member questions. Running maintenance scripts, or other ad-hoc tasks. The "Oh #&@$" Solution These small support tasks start to add up, and there is no easy way to offload the work to product, or support. You and your development team reach a breaking point where you must start prioritizing administrative tools. Unfortunately, you will slow down your new feature development even more in the short term. It will take time for those tools to be developed, and take effect to lower your support load. Read more The Prototype Maturity Model August 15, 2022 Image Moving Fast, Taking Shortcuts Not all ideas are good ideas. In fact, the majority of your ideas will not be good. When it comes to building software, it doesn't make much sense to build out a prototype of a new idea with all the best practices a full fledged software product requires. This is why we rapidly prototype software for a new feature, or product. We want to validate the idea as quickly as possible. This is the whole philosophy behind the minimal viable product or MVP that start ups are always talking about. But MVP doesn't just apply to startups. There are ideas that can be prototyped in the biggest companies that could save the company employees hundreds of hours of time, or millions of dollars. So, you decide your idea might have some merit. You meet with the team, and list out the core, minimal features. Everyone is on board, and your team has the green light to build a prototype. Now, the hard decisions start to pile up. It's tempting to over-engin Read more Powered by Blogger Report Abuse