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.