Tutorial
Share LiveView state between tabs
This post was updated 01 May - 2020
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 betweeen browers, is by having a GenServer holding a map with all current browser sessions and the data attached to it.
And to keep this to grow 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 updated the Genserver with the coordinates.
Background
I already have an 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 look like this:
What we are building
A LiveView component is built on top of a GenServer and is actually capabale to hold state during its life time. But in this case I want a shared server that holds to truth and all LiveView processes that spawns, will start with 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 draggable.html.eex
template and I know I want to move it into a LiveView I will start with creating the LiveView.
# lib/tutorial_web/live/draggable_live.ex
defmodule TutorialWeb.DraggableLive do
use Phoenix.LiveView
def mount(%{}, socket) do
assigns = [
x: 10,
y: 10
]
{:ok, assign(socket, assigns)}
end
def render(assigns) do
~L"""
<div style="transform: translate(<%= @x %>px, <%= @y %>px);" phx-hook="Draggable" 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 template like
<!-- draggable.html.eex -->
<%= live_render @conn, TutorialWeb.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 its 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 its very easy to setup.
Just create a presence.ex
file:
# lib/tutorial/presence.ex
defmodule Tutorial.Presence do
use Phoenix.Presence, otp_app: :tutorial, pubsub_server: Tutorial.PubSub
end
And when the application starts, we need to make sure our Presence process starts as well.
# lib/tutorial/application.ex
children = [
...
# Starts Presence Process
Tutorial.Presence
]
That is basically it to get started. However, to actually start track, I need a code snippet like:
{:ok, _} = Presence.track(self(), "tutorial: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 non exists for a new visitor
# lib/tutorial_web/plugs/assign_session.ex
defmodule TutorialWeb.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 the router in the browser pipeline
# lib/tutorial_web/router.ex
plug TutorialWeb.AssignSession
STEP 3 - Setup the Draggable GenServer
This genserver will do
- 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_id
from the state
defmodule Tutorial.DraggableServer do
use GenServer
alias Tutorial.PubSub
@default_coordinates {10, 10}
def init(_opts) do
Phoenix.PubSub.subscribe(PubSub, "tutorial: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_cordinates, 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_cordinates, 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 ->
Tutorial.Presence.list("tutorial: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 start so in application.ex
add:
# lib/tutorial/application.ex
children = [
...
Tutorial.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:
- Tracks new visitors in
Presence
with thesession_id
- It subscribes to events or new coordinates from
DraggableServer
- It get the current coordinates from the session and assigns them to the socket.
So entire file:
defmodule TutorialWeb.DraggableLive do
use Phoenix.LiveView
alias Tutorial.DraggableServer
alias Tutorial.Presence
def mount(%{"session_id" => session_id}, socket) do
if connected?(socket) do
# As soon as a new instance if the LiveView is mounted, track the session id
{:ok, _} = Presence.track(self(), "tutorial: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
~L"""
<div style="transform: translate(<%= @x %>px, <%= @y %>px);"
phx-hook="Draggable" 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 as side by side. As soon as a box is moved in one tab, I broadcast the movement to the LiveView that in turn updated the Genserver with the coordinates.