Communicating Between Live View Components, Backends and other Channels.


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

  1. Dependency Addition: Add Syn and elixir_uuid as a dependency in mix.exs.
      {:syn, "~> 3.3"},
      { :elixir_uuid, "~> 1.2" }

  1. 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
  1. 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
  1. 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.”

  1. 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?

Leave a Reply

Your email address will not be published. Required fields are marked *