Tutorial
Share LiveView state between tabs
This post was updated 27 Mar
Each LiveView on each tab spawns a separate state. That might or might not be the desired behaviour. In this tutorial, I am going to share state between tabs/windows for a specific browser session.
And the way I share state between browsers, is by having a GenServer holding a map with all current browser sessions and the data attached to it.
And to keep this from growing indefinitely, I am also going to use Phoenix Presence to clean up state when the last browser window or tab is closed.
Final Result
As soon as a box is moved in one tab, I broadcast the movement to the LiveView that in turn updates the GenServer with the coordinates.
Background
I already have some preparation for this tutorial. See this commit
I use a JS library called interact.js to implement a draggable effect.
And the starting point looks like this:
What we are building
A LiveView component is built on top of a GenServer and is actually capable of holding state during its life time. But in this case I want a shared server that holds the truth and all LiveView processes that spawn will start by asking the GenServer for the truth. And as soon as the box is moved, the central GenServer is updated and the state is then shared between connected LiveViews.
So something like:
Step 1 - Create the initial LiveView
Since we already are in a state where we have a draggable div in the template and I know I want to move it into a LiveView, I will start with creating the LiveView.
# lib/my_app_web/live/draggable_live.ex
defmodule MyAppWeb.DraggableLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
assigns = [
x: 10,
y: 10
]
{:ok, assign(socket, assigns)}
end
def render(assigns) do
~H"""
<div style={"transform: translate(#{@x}px, #{@y}px);"} phx-hook="Draggable" id="draggable-box" class="item h-20 w-20 border border-gray-500 bg-blue-200 shadow"></div>
"""
end
def handle_event("moving", %{"x" => x, "y" => y}, socket) do
assigns = [
x: (socket.assigns.x || 0) + x,
y: (socket.assigns.y || 0) + y
]
{:noreply, assign(socket, assigns)}
end
end
And mount it in the router like:
# lib/my_app_web/router.ex
live "/draggable", DraggableLive
Note that I add a LiveView hook called Draggable phx-hook="Draggable". What I want to do is to initialize interact.js as soon as it’s mounted.
So, I need to add that LiveView hook and add:
Hooks.Draggable = {
mounted() {
let instance = this
interact(this.el).draggable({
onmove(event) {
instance.pushEvent("moving", {x: event.dx, y: event.dy})
}
})
}
}
Note that pushEvent will send an event to the LiveView component and that is handled by the handle_event("moving", %{"x" => x, "y" => y}, socket).
We are now at a position where the box is still draggable, just as before. And with that, I can clean out the other JS from the setup.
STEP 2 - Setup Phoenix Presence
This is no spoiler. I have already stated that I need to use this and it’s very easy to setup.
Just create a presence.ex file:
# lib/my_app/presence.ex
defmodule MyApp.Presence do
use Phoenix.Presence, otp_app: :my_app, pubsub_server: MyApp.PubSub
end
And when the application starts, we need to make sure our Presence process starts as well.
# lib/my_app/application.ex
children = [
...
# Starts Presence Process
MyApp.Presence
]
That is basically it to get started. However, to actually start tracking, I need a code snippet like:
{:ok, _} = Presence.track(self(), "my_app:presence", session_id, %{
session_id: session_id,
joined_at: :os.system_time(:seconds)
})
Note that I have a session_id here. I need to create a plug that assigns a random session_id if none exists for a new visitor.
# lib/my_app_web/plugs/assign_session.ex
defmodule MyAppWeb.AssignSession do
import Plug.Conn, only: [get_session: 2, put_session: 3]
def init(options), do: options
def call(conn, _opts) do
case get_session(conn, :session_id) do
nil -> put_session(conn, :session_id, Ecto.UUID.generate())
_ -> conn
end
end
end
And then mount it in the router in the browser pipeline:
# lib/my_app_web/router.ex
plug MyAppWeb.AssignSession
STEP 3 - Setup the Draggable GenServer
This GenServer will:
- Subscribe to the presence channel
- Store the states of coordinates assigned to a session_id
- Return the current state for a new LiveView or fallback to
x: 10, y: 10 - When the last user of a session is gone, remove that
session_idfrom the state
# lib/my_app/draggable_server.ex
defmodule MyApp.DraggableServer do
use GenServer
alias MyApp.PubSub
@default_coordinates {10, 10}
def init(_opts) do
Phoenix.PubSub.subscribe(PubSub, "my_app:presence")
state = %{}
{:ok, state}
end
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def subscribe(session_id), do: Phoenix.PubSub.subscribe(PubSub, "draggable:#{session_id}")
def set_coordinates(session_id, coordinates) do
GenServer.cast(__MODULE__, {:set_coordinates, session_id, coordinates})
end
def get_coordinates(session_id) do
GenServer.call(__MODULE__, {:get_coordinates, session_id})
end
# Internal interface
def handle_cast({:set_coordinates, session_id, coordinates}, state) do
Phoenix.PubSub.broadcast(PubSub, "draggable:#{session_id}", {:updated_coordinates, coordinates})
{:noreply, Map.put(state, session_id, coordinates)}
end
def handle_call({:get_coordinates, session_id}, _from, state) do
{:reply, Map.get(state, session_id, @default_coordinates), state}
end
def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff", payload: diff}, state) do
state =
state
|> handle_leaves(diff.leaves)
{:noreply, state}
end
defp handle_leaves(state, leaves) do
Enum.reduce(leaves, state, fn {session_id, _}, state ->
MyApp.Presence.list("my_app:presence")
|> Map.get(session_id)
|> case do
nil -> Map.delete(state, session_id)
_ -> state
end
end)
end
end
The GenServer needs to start when the application starts so in application.ex add:
# lib/my_app/application.ex
children = [
...
MyApp.DraggableServer
]
STEP 4 - Update the LiveView Component
Lastly I need to update the LiveView component. When the component mounts, I need to make sure that:
- It tracks new visitors in
Presencewith thesession_id - It subscribes to events for new coordinates from
DraggableServer - It gets the current coordinates from the session and assigns them to the socket
So the entire file:
# lib/my_app_web/live/draggable_live.ex
defmodule MyAppWeb.DraggableLive do
use MyAppWeb, :live_view
alias MyApp.DraggableServer
alias MyApp.Presence
def mount(_params, %{"session_id" => session_id}, socket) do
if connected?(socket) do
# As soon as a new instance of the LiveView is mounted, track the session id
{:ok, _} = Presence.track(self(), "my_app:presence", session_id, %{
session_id: session_id,
joined_at: :os.system_time(:seconds)
})
DraggableServer.subscribe(session_id)
end
{x, y} = DraggableServer.get_coordinates(session_id)
assigns = [
x: x,
y: y,
session_id: session_id
]
{:ok, assign(socket, assigns)}
end
def render(assigns) do
~H"""
<div style={"transform: translate(#{@x}px, #{@y}px);"}
phx-hook="Draggable" id="draggable-box" class="item h-20 w-20 border border-gray-500 bg-blue-200 shadow"></div>
"""
end
def handle_event("moving", %{"x" => x, "y" => y}, socket) do
coordinates = {socket.assigns.x + x, socket.assigns.y + y}
DraggableServer.set_coordinates(socket.assigns.session_id, coordinates)
{:noreply, socket}
end
def handle_info({:updated_coordinates, {x, y}}, socket) do
assigns = [
x: x,
y: y
]
{:noreply, assign(socket, assigns)}
end
end
Final Result
So this is now ready to be tested again. I need to have two tabs open side by side. As soon as a box is moved in one tab, I broadcast the movement to the LiveView that in turn updates the GenServer with the coordinates.