Reduce boilerplate code for data loading in LiveView

Hello everybody!

I’m using Phoenix Live View for a while and I like it so much!
I need your opinions and suggestions about something that I’m uncomfortable during my developing, because I’m not expert of Elixir and I don’t know if my mindset is correct.
Sorry for the long post :smiley:

While coding I noticed that I wrote the same thing everytime: I need to load some data into a live view.
I would like to explain you how I use to manage my live views, what I have done to reduce boilerplate and what are your suggestion about that.

How I implement a live view

Typically my view state is in the url/query params (updated by the inputs), so I create a function that takes params and socket and returns the socket with the data assigned:

def load_resource_name(socket, params) do
  resource = get_resource_name(params)
  # optionally subscribe(PubSub, ....)
  assign(socket, :resource_name, resource)
end

def get_resource_name(params) do
  # parse/transform/validate params
  # load the resource from DB or something else
  # ...
end

In a view I always need more than one resource so the above code must be “duplicated” for each resource.
Then I need to load my resource on mount, on handle_params and on handle_info (because I listen for data change via PubSub).
So I write:

def mount(params, _session, socket) do
  {:ok,
    socket
    |> load_resource_a(params)
    |> load_resource_b(params)
    |> load_resource_c(params)}
end
def handle_params(params, _uri, socket) do
  {:noreply,
    socket
    |> load_resource_a(params)
    |> load_resource_b(params)
    |> load_resource_c(params)}
end
def handle_info(message, socket) do
  {:noreply,
    socket
    |> load_resource_a(message)
    |> load_resource_b(message)
    |> load_resource_c(message)}
end

Please ignore that handle_info has a message and not params as argument, it is not so important now.
First refactor is for sure to create a function that load everything and use it in each live view event:

def load_all_resources(socket, params) do
    socket
    |> load_resource_a(message)
    |> load_resource_b(message)
    |> load_resource_c(message)
end
def mount(params, _session, socket) do
  {:ok, load_all_resources(socket, params)}
end
# the same for handle_params and handle_info ...

That is my pain, do you do the same?

My solution
I read many times the big disclaimer about macros :smiley: , but I think that maybe this is a right case to use them.
I ended up with something like the following:

defmodule BurellixyzWeb.LiveViewData do
  defmacro __using__(_env) do
    quote do
      import BurellixyzWeb.LiveViewData

      @load_fns []
      @reload_fns []

      @before_compile BurellixyzWeb.LiveViewData
    end
  end

  defmacro loader(resource_name, get_resource_opts \\ nil) do
    resource_name_atom = String.to_atom(resource_name)
    get_resource_fn = String.to_atom("get_" <> resource_name)
    load_resource_fn = String.to_atom("load_" <> resource_name)
    reload_resource_fn = String.to_atom("reload_" <> resource_name)

    quote do
      @load_fns [unquote(load_resource_fn) | @load_fns]
      @reload_fns [unquote(reload_resource_fn) | @reload_fns]

      def unquote(load_resource_fn)(socket, params) do
        resource = unquote(get_resource_fn)(params, socket, unquote(get_resource_opts))
        assign(socket, unquote(resource_name_atom), resource)
      end

      def unquote(reload_resource_fn)(socket, message \\ nil) do
        resource = unquote(get_resource_fn)(message, socket, unquote(get_resource_opts))
        assign(socket, unquote(resource_name_atom), resource)
      end
    end
  end

  defmacro __before_compile__(_env) do
    quote do
      def load_all_resources(socket, params) do
        @load_fns
        |> Enum.reverse()
        |> Enum.reduce(socket, fn name, socket ->
          apply(__MODULE__, name, [socket, params])
        end)
        |> assign(:params, params)
      end

      def reload_all_resources(socket, message) do
        @reload_fns
        |> Enum.reverse()
        |> Enum.reduce(socket, fn name, socket ->
          apply(__MODULE__, name, [socket, message])
        end)
      end
    end
  end
end

So the developer have to write only the function to get the resource, something like this:

use BurellixyzWeb.LiveViewData

def get_my_resource(params, socket) do
  # load resource from DB or other source ...
end

loader("my_resource")

Macros will generate the others functions and the functions the group them all.
And, in simple cases, to avoid writing live view callbacks, developer can use the following macro:

  defmacro default_callbacks do
    quote do
      def mount(params, _session, socket) do
        {:ok, load_all_resources(socket, params)}
      end

      def handle_params(params, _uri, socket) do
        {:noreply,
         socket
         |> load_all_resources(params)}
      end

      def handle_info(message, socket) do
        {:noreply,
         socket
         |> reload_all_resources(message)}
      end

      defoverridable mount: 3, handle_params: 3, handle_info: 2
    end
  end

instead of live view callbacks, developer writes:

default_callbacks()

What do you think?
Many thanks!

Are the resources you’re loading connected/related in any way? If so, a common pattern would be creating a context module with a function that fetches all the related data from the database in one shot and letting the database do the heavy lifting via a well constructed Ecto query. Here’s an example from the docs where a shopping cart struct is fetched along with its associated items and products:

def get_cart_by_user_uuid(user_uuid) do
    Repo.one(
      from(c in Cart,
        where: c.user_uuid == ^user_uuid,
        left_join: i in assoc(c, :items),
        left_join: p in assoc(i, :product),
        order_by: [asc: i.inserted_at],
        preload: [items: {i, product: p}]
      )
    )
  end

source: Adding Catalog functions | Phoenix Contexts Guide

Also, it looks like there may be some duplicate work being done between mount and handle_params given how the latter will always run after the former in a LiveView’s lifecycle.

And out of curiosity, is there a specific reason you’re reloading all the resources from the database in the handle_info callback? If you’re handling messages related to making database changes using Ecto for example, then the Ecto’s Repo.insert/delete/update functions will return a struct that can then be directly assigned to the socket.

1 Like

Resources could be related or not, or better I need some “view model data” also: in a view I could have a model from the DB (loaded via a context module, ecto and so on), a changeset for a form, a list of dates formatted for the user for filtering etc. So a single call with a join is not enough.

Interesting that handle_params will always run after mount, thanks for pointing it out, I missed it.

I use handle_info and not the result of a Repo.insert/update/delete because handle_info message could come from an other live view instance, an other user that change the same data so I propagate changes via PubSub (in the context module) and update all views instances to have real time data update.

It’s possible to achieve this without hitting the database in the handle_info callback by broadcasting the result of the Ecto operation. For an example, see how this broadcast sends a struct/map representing an updated card.

defmodule CardComponent do
  ...
  def handle_event("update_title", %{"title" => title}, socket) do
    message = {:updated_card, %{socket.assigns.card | title: title}}
    Phoenix.PubSub.broadcast(MyApp.PubSub, board_topic(socket), message)
    {:noreply, socket}
  end

  defp board_topic(socket) do
    "board:" <> socket.assigns.board_id
  end
end

defmodule BoardView do ... 
  def handle_info({:updated_card, card}, socket) do 
    # update the list of cards in the socket     
    {:noreply, updated_socket} 
  end 
end

source: Managing State | Phoenix LiveView docs

Taking a step back, hitting the database just once in a handle_info callback in response to a PubSub.broadcast would potentially generate n calls to the database where n is the number of clients subscribed to that topic.

Yes, you are right, I agree.

My point is that everytime I have to write the same code so I would like to use macro to avoid boilerplate code, something like this:

defmodule MyView do
  use MyMacro

  def render(assigns) do
    # ... @resource_a @resouce_b @changeset_a ...
  end

  MyMacro.loader("resource_a", fn params, socket ->
    # validate/transform params
    Context.get_resource_a(...)
  end,
  fn -> Context.subscribe_resource_a(...) end,
  fn message, socket ->
    # update resource using message
  end)

  MyMacro.loader("resource_b", fn params, socket ->
    # validate/transform params
    Context.get_resource_b(...)
  end,
  fn -> Context.subscribe_resource_b(...) end,
  fn message, socket ->
    # update resource using message
  end)

  MyMacro.loader("changeset_a", fn params, socket ->
    # validate/transform params
    socket.assigns.resource_a |> Context.changeset() 
  end,
  nil,
  fn message, socket ->
    socket.assigns.resource_a |> Context.changeset() 
  end)
end

And then macro will generate handle_params and handle_info to assign all resources to the socket.
What do you think?

I’d strongly suggest taking a look at on_mount and attach_hook rather than rolling a custom solution for doing things when callbacks are triggered.

2 Likes