By default, a LocalLiveView is fully self-contained in the browser — the server knows nothing about its state. Mirror Sync is the mechanism for bridging that gap: it lets a LocalLiveView push selected assigns to the server, where a server-side module can react to them.
When to use it
Use Mirror Sync when you need the server to be aware of local state, for example:
- Broadcasting local state changes to other connected users via PubSub
- Persisting user input to a database
- Letting a server-side Phoenix LiveView display or react to local state
How it works
Browser Server
┌─────────────────────┐ ┌───────────────────────────┐
│ MyLocal │ │ Mirror.MyLocal │
│ handle_event(...) │ │ handle_sync( │
│ mirror_sync( │──sync───▶│ local_assigns, │
│ socket, │ │ mirror_assigns, │
│ [:count] │ │ params │
│ ) │ │ ) │
└─────────────────────┘ └───────────────────────────┘- Your LocalLiveView calls
mirror_sync/2with the socket and a list of assign keys. - The JS bridge sends those assigns to the server over a Phoenix Channel.
- The server finds
Mirror.<ViewName>and calls itshandle_sync/3callback.
Setting up mirror sync
1. Declare the mirror keys
Pass the keys you want to sync when calling mirror_sync/2:
defmodule MyLocal do
use LocalLiveView
def mount(_params, _session, socket) do
{:ok, assign(socket, count: 0)}
end
def render(assigns) do
~H"""
<button phx-click="increment">Count: {@count}</button>
"""
end
def handle_event("increment", _params, socket) do
socket = update(socket, :count, &(&1 + 1))
mirror_sync(socket, [:count])
{:noreply, socket}
end
endmirror_sync/2 takes the socket and a list of assign keys to send. It returns the socket unchanged, so you can pipe it or ignore the return value.
2. Create the Mirror module
On the server side, create lib/mirror/my_live.ex:
defmodule Mirror.MyLocal do
use LocalLiveView.Mirror
@impl true
def handle_sync(local_assigns, _mirror_assigns, _params) do
{:ok, local_assigns}
end
endThe module must be named Mirror.<ViewName> — the view name is the last part of your LocalLiveView module name. LocalLiveView.Component auto-detects the mirror module and enables the sync channel when rendering the mount point.
handle_sync/3 receives:
local_assigns— a map of the synced assigns (keys are strings)mirror_assigns— the mirror's current state (what was returned from the previoushandle_synccall)params- a map containing its mirror_id (mirror_id is a composition of parent LV socket id and a LLV dom element id which are assembled viaLocalLiveView.Component.mirror_id/2function)
It must return {:ok, new_mirror_assigns}.
Broadcasting via PubSub
The most common use case is broadcasting local state to other LiveViews:
defmodule Mirror.MyLocal do
use LocalLiveView.Mirror
@impl true
def handle_sync(local_assigns, _mirror_assigns) do
Phoenix.PubSub.broadcast(
MyApp.PubSub,
"llv_mirror:MyLocal",
{:llv_attrs, local_assigns}
)
{:ok, local_assigns}
end
endThen subscribe and handle in a server-side LiveView:
defmodule MyAppWeb.DashboardLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "llv_mirror:MyLocal")
{:ok, assign(socket, count: 0)}
end
def handle_info({:llv_attrs, %{"count" => count}}, socket) do
{:noreply, assign(socket, count: count)}
end
endMirror assigns and conflict resolution
handle_sync/3 can return different assigns than it received — this is useful when you want the mirror to store derived or enriched state:
def handle_sync(%{"count" => count} = local_assigns, mirror_assigns) do
enriched = Map.put(local_assigns, "total_increments", Map.get(mirror_assigns, "total_increments", 0) + 1)
{:ok, enriched}
endThe returned value becomes mirror_assigns in the next call, but it is not sent back to the browser — it only lives on the server.
Sync frequency
mirror_sync/2 sends over the wire every time it's called. Call it only when state has changed and the server needs to know — typically at the end of handle_event/3 when relevant assigns were updated.
Serialization
Assigns are serialized as a JSON-compatible map before being sent. The following types are supported:
- Primitives: strings, numbers, booleans, nil
- Lists
- Maps (keys are converted to strings)
- Structs (converted to maps via
Map.from_struct/1)
Atoms, tuples, and PIDs are not serializable and will cause a runtime error if included.