Overview
In larger live view applications containing multiple views/components per page some without direct child-parent relationships the need to communicate messages between sections occasionally will come up. Global events like login/logout, system notifications from the back end, pushing config changes from a config view out to the existing nonrelated widgets on page, etc.
Although documentation is poor online for this edge case there are generally 4 common approaches to handling this scenario. 1. Phoenix Live View PubSub (great, built in, what I went with initially). 2. Parent-Child Communication: falls apart when dealing with kissing cousins/the logical/visual groupings of components are not logically grouped but cross communication is reasonable and expected. Session based message passing (will fall apart with nested views which lack access to global session and require passing data down the chain). 4. Shared State Management / ets , Gen server, etc.
When I first came across this problem, I utterly failed in finding online documentation providing a clean solution for implementing message passing. And so I eked out a pub sub solution from scratch that worked reasonably well for my needs.
# -------------------------------------------------------------------------------
# Author: Keith Brings
# Copyright (C) 2023 Noizu Labs, Inc. All rights reserved.
# -------------------------------------------------------------------------------
defmodule NoizuIntellect.LiveEventModule do
@moduledoc """
The LiveEventModule is used for subscribing to and receiving events between LiveView Components
for universal state updates such as login/logout events where standard methods fall apart.
"""
require Record
require Logger
alias Phoenix.PubSub
Record.defrecord(:event, subject: nil, instance: nil, event: nil, payload: nil, options: nil)
def unsubscribe(
event(
subject: s_subject,
instance: s_instance,
event: s_event
)
) do
key =
[s_subject, s_instance, s_event]
|> Enum.map(&"#{&1 || "*"}")
|> Enum.join(":")
# Logger.warn("PUBSUB Unsubscribe: #{key}")
PubSub.unsubscribe(NoizuIntellect.LiveViewEvent, key)
end
def subscribe(
msg =
event(
subject: s_subject,
instance: s_instance,
event: s_event
)
) do
IO.inspect(msg, label: :subscribe)
key =
[s_subject, s_instance, s_event]
|> Enum.map(&"#{&1 || "*"}")
|> Enum.join(":")
# Logger.warn("PUBSUB Subscribe: #{key}")
PubSub.subscribe(NoizuIntellect.LiveViewEvent, key)
end
def publish(
event(
subject: s_subject,
instance: s_instance,
event: s_event,
payload: _payload
) = msg
) do
IO.inspect(msg, label: :publish)
# This is super inefficient, better routing will be needed in the future.
# - Consider just switching to Syn and dedicating a message coordinater per User or User Session, although there are some upsides to pushing updates
# - via pub sub for keeping pages synched across users/devices/sessions with out needing to add a bunch of addtiional logic.
keys = [
"#{s_subject}:*:*",
"#{s_subject}:#{s_instance}:*",
"#{s_subject}:#{s_instance}:#{s_event}",
"#{s_subject}:*:#{s_event}"
]
# Logger.info("PUB-SUB-EMIT: #{inspect keys} -> #{inspect msg}")
Enum.map(keys, fn key ->
PubSub.broadcast(
NoizuIntellect.LiveViewEvent,
key,
msg
)
end)
:ok
end
end
Like my disparaging notes however, message broadcast was unnecessarily inefficient. Glancing over some live view code this afternoon I decided to come up with a more flexible solution.
Syn For Message Passing.
Syn is a fantastic message passing/routing/registry framework I’ve been in the process of shifting my scaffolding libraries over to from my older and less efficient (or reliable at least) message passing infra.
I strongly recommend perusing their documentation.
Implementation
- Dependency Addition: Add Syn and elixir_uuid as a dependency in
mix.exs
.
{:syn, "~> 3.3"},
{ :elixir_uuid, "~> 1.2" }
- Initialization: Initialize Syn within your application, typically in the application start function.
def application do
[
mod: {LiveViewEventPassingDemo.Application, []},
extra_applications: [:logger, :runtime_tools, :syn]
]
end
- Optional Setup Unique per browser/session identifier. I did so with a quick and dirty plug to inject a unique id into session. This is for avoiding broadcasting messages to all users when they are scoped to the current user session/page view.
defmodule LiveViewEventPassingDemo.OTP.LiveViewDispatcher.Plug do
import Plug.Conn
def init(default), do: default
def call(conn, _opts) do
case get_session(conn, :live_view_broadcaster_session) do
nil ->
# Generate a new session ID, for example a UUID
new_session_id = UUID.uuid4()
conn
|> put_session(:live_view_broadcaster_session, new_session_id)
_ ->
conn
end
end
end
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {LiveViewEventPassingDemoWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug LiveViewEventPassingDemo.OTP.LiveViewDispatcher.Plug
end
defmodule LiveViewEventPassingDemoWeb.Live.Widgets.WidgetA do
use LiveViewEventPassingDemoWeb, :live_view
@impl true
def mount(_params, session, socket) do
sess = session["live_view_broadcaster_session"]
# ...
{:ok,
socket
|> assign(last_event: nil)
}
end
- Next with per session identifier we setup a syn convenience method for wrapping/managing publishing/subscribing to messages groups. I created this as a GenServer to allow hooking into the application tree and calling the required syn:add_node_to_scopes method on application start. Although this could have been inline’d in the application start method relying on genserver gives some future wiggle room for building out the feature further.
The GenServer is added to the application supervisor’s child list.
{LiveViewEventPassingDemo.OTP.LiveViewDispatcher, []},
defmodule LiveViewEventPassingDemo.OTP.LiveViewDispatcher do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, :ok, name: {:global, __MODULE__})
end
@impl true
def init(:ok) do
:syn.add_node_to_scopes([:live_view_dispatch])
{:ok, %{}}
end
def subscribe(session) do
join = :syn.join(:live_view_dispatch, session, self())
{join, session}
end
def publish(group, message) do
:syn.publish(:live_view_dispatch, group, message)
end
end
This gives is just a basic bare bones syn backed pub sub solution for message passing. There’s a lot that could still be done here to flesh out message passing further for communication between views, backend systems and other channels. Defining a standardized message dispatch record as I did in my original solution would be one starting point.
Record.defrecord(:event, subject: nil, instance: nil, event: nil, payload: nil, options: nil)
Using the syn dictionary to track additional details/filtering for dispatching messages, etc. are options as well. But it’s enough to solve 80% of the edge cases I’ve come across so far and more elegantly than the pubsub solution.
Now, with this basic system in place, we define some live views nested on the same page and let them pass messages between one another.
<div class="widget-container relative">
<div class="widget-row">
<%= live_render(@conn, LiveViewEventPassingDemoWeb.Live.Widgets.WidgetA) %>
<%= live_render(@conn, LiveViewEventPassingDemoWeb.Live.Widgets.WidgetB) %>
</div>
<div class="widget-row">
<div class="circle">
<%= live_render(@conn, LiveViewEventPassingDemoWeb.Live.Widgets.WidgetCircle) %>
</div>
</div>
<div class="widget-row">
<%= live_render(@conn, LiveViewEventPassingDemoWeb.Live.Widgets.WidgetC) %>
</div>
</div>
And we hookup our channels to subscribe to
defmodule LiveViewEventPassingDemoWeb.Live.Widgets.WidgetCircle do
use LiveViewEventPassingDemoWeb, :live_view
@impl true
def mount(_params, session, socket) do
sess = session["live_view_broadcaster_session"]
{:ok, broadcast_group} = LiveViewEventPassingDemo.OTP.LiveViewDispatcher.subscribe(sess)
LiveViewEventPassingDemo.OTP.LiveViewDispatcher.subscribe({broadcast_group, :widget_circle})
{:ok,
socket
|> assign(broadcast_group: broadcast_group)
}
end
broadcast
@impl true
def handle_event("broadcast", _, socket) do
LiveViewEventPassingDemo.OTP.LiveViewDispatcher.publish(socket.assigns[:broadcast_group], :hello_everyone)
{:noreply, socket}
end
and handle incoming messages.
def handle_info(event, socket) do
socket = socket
|> assign(last_event: event)
{:noreply, socket}
end
end
Giving us,
Source Code
The full project is here, if I make any major improvements, I’ll update this post and the repo. noizu/live_view_event_passing_demo (github.com)
One response to “Communicating Between Live View Components, Backends and other Channels.”
Write more, thats all I have to say. Literally, it seems as though you relied on the video to make your point. You clearly know what youre talking about, why throw away your intelligence on just posting videos to your blog when you could be giving us something informative to read?