Andrew Timberlake Andrew Timberlake

Hi, I’m Andrew, a programmer and entrepreneur from South Africa,
building Mailcast for taking control of your email.
Thanks for visiting and reading.


How to respond to channel broadcasts in a Phoenix LiveView app layout (global broadcasts)

In a project I’m working on, I have a menu in my app layout that displays a number of unread messages. Because this is in the app layout, it does not belong to any one LiveView instance, but to all of them. When a new message comes in, or a message is marked as read, there is a broadcast on a PubSub channel that communicates the unread change. While you can subscribe to a channel within a LiveView, you can’t subscribe to a channel across all LiveViews in order to update what is essentially global state.

Subscribing to a channel within a LiveView

For some quick background, this is how you subscribe to, and respond to channel messages within a LiveView

defmodule MyAppWeb.MyLiveView do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    MyAppWeb.Endpoint.subscribe("message_channel:<user_id>")
    socket = assign(socket, :unread_count, 0)
    {:ok, socket}
  end

  def handle_info(%{topic: "message_channel:<user_id>",
                    event: "unread_count_changed",
                    payload: %{change: change}}, socket) do
    # Do something with the unread count change
    socket = assign(socket, :unread_count, socket.assigns.unread_count + change)

    {:noreply, socket}
  end

  # Ignore all other events
  def handle_info(_event, socket) do
    # Or log them with a warning
    {:noreply, socket}
  end
end

Global events with a LiveComponent?

My first thought was to create a LiveComponent that would subscribe to the channel and update the state accordingly. But a LiveComponent shares the same process as it’s parent LiveView. This means that a LiveComponent in the app layout shares the same process as whichever LiveView is rendering at that time.
While a LiveComponent has a mount callback, if you subscribe to a channel broadcast, it will send info messages to the parent LiveView. You can see this in how LiveComponents handle events—for a LiveComponent to receive an event, it must be explicity targeted using phx-target={@myself}.

So with LiveComponents out of the picture, how do you subscribe to a channel across all LiveViews?

What about Live Sessions?

A live session defines a group of LiveViews that can navigate between each other within the same websocket connection. When defining a live session, you can pass in an on_mount option that will be called for each LiveView in the session. This seems like a good place to subscribe to a channel broadcast that will be shared across all LiveViews in the session. But you are still faced with the problem that each LiveView will need to handle the broadcast message. We still need a way to handle the broadcast message globally, rather than in each LiveView.

Handle the broadcast message in the app’s web module

Each Phoenix application defines a Web module that provides macros for setting up a LiveView (as well as components, controllers, etc). Using this module, we can set up a handle_info/2 callback that will be applied to all LiveViews when they are created.

defmodule MyAppWeb do
  # …
  def live_view do
    quote do
      use Phoenix.LiveView,
        layout: {MyAppWeb.Layouts, :app}

      def handle_info(%{topic: "message_channel:<user_id>",
                        event: "unread_count_changed",
                        payload: %{change: change}}, socket) do
        # Do something with the unread count change
        socket = assign(socket, :unread_count, socket.assigns.unread_count + change)
        {:noreply, socket}
      end

      unquote(html_helpers())
    end
  end
  # …
end

MyAppWeb.live_view/0 looks like the perfect place to handle our subscription messages, but it needs to be done in such a way that we don’t clobber the ability of each LiveView to subscribe to and handle other information messages that might come along.

If we simple declare a handle_info/2 callback in this function, we will have an undefined error when any unexpected info message arrives at any LiveView. We can define a handle_info/2 callback that handles unexpected messages and perhaps logs them.

defmodule MyAppWeb do
  require Logger
  # …
  def live_view do
    quote do
      use Phoenix.LiveView,
        layout: {MyAppWeb.Layouts, :app}

      def handle_info(%{topic: "message_channel:<user_id>",
                        event: "unread_count_changed",
                        payload: %{change: change}}, socket) do
        # Do something with the unread count change
        socket = assign(socket, :unread_count, socket.assigns.unread_count + change)
        {:noreply, socket}
      end

      # Handle any unexpected messages by logging a warning
      def handle_info(msg,  %{view: view} = socket) do
        Logger.warning("undefined handle_info in #{inspect(view)}. Unhandled message: #{inspect(payload)}")
        {:noreply, socket}
      end

      unquote(html_helpers())
    end
  end
  # …
end

The problem with this approach is, we lose the ability to handle other info messages in each LiveView because our default handle_info/2 will be invoked before any handle_info/2 defined in the LiveView—because these have been defined first.

The final solution

The solution is to use some macro tools provided by Elixir which allow us to add our handle_info/2 callback as well as add our catch-all callback, but at the end of the module. To do this, we make use of the @before_compile attribute which will allow us to add another callback at the end of each LiveView, after any handle_info/2 callbacks defined within the LiveView.

defmodule MyAppWeb do
  require Logger
  # …
  def live_view do
    quote do
      @before_compile {MyAppWeb, :live_view_handle_info_warning}

      use Phoenix.LiveView,
        layout: {MyAppWeb.Layouts, :app}

      def handle_info(%{topic: "message_channel:<user_id>",
                        event: "unread_count_changed",
                        payload: %{change: change}}, socket) do
        # Do something with the unread count change
        socket = assign(socket, :unread_count, socket.assigns.unread_count + change)
        {:noreply, socket}
      end

      unquote(html_helpers())
    end
  end

  defmacro live_view_handle_info_warning(_env) do
    quote do
      # Handle any unexpected messages by logging a warning
      def handle_info(payload,  %{view: view} = socket) do
        Logger.warning("undefined handle_info in #{inspect(view)}. Unhandled message: #{inspect(payload)}")
        {:noreply, socket}
      end
    end
  end
  # …
end

defmodule MyAppWeb.Router do
  # …
  scope "/", MyAppWeb do
    pipe_through :browser

    live_session :default, on_mount: MyAppWeb.GlobalEvents do
      live_view "/", MyLiveView
    end
  end
  # …
end

defmodule MyAppWeb.GlobalEvents do
  def on_mount(:default, _params, _session, socket) do
    MyAppWeb.Endpoint.subscribe("message_channel:<user_id>")
    socket = assign(socket, :unread_count, 0)
    {:cont, socket}
  end
end

This solution solves our requirement of handling a broadcast at the global level while leaving each LiveView with the ability to subscribe to, and handle information messages as it normally does.

I hope this helps you. If you have any questions or comments, feel free to reach out to me via email or on X.

17 Jul 2024