Tutorial

Share LiveView state between tabs

This post was updated 27 Mar

javascript liveview phoenix

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:

  1. Subscribe to the presence channel
  2. Store the states of coordinates assigned to a session_id
  3. Return the current state for a new LiveView or fallback to x: 10, y: 10
  4. When the last user of a session is gone, remove that session_id from 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:

  1. It tracks new visitors in Presence with the session_id
  2. It subscribes to events for new coordinates from DraggableServer
  3. 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.

Related Tutorials

NEW
Published 11 Apr

Rendering an Activity Feed in Phoenix LiveView

In the previous tutorial, we built an automatic activity tracking system that records events whenever a tracked schema is inserted, updated, or dele..

Published 29 Jan - 2020
Updated 27 Mar

Send events from JS to a LiveView component

Let say you app uses a javascript library that needs to interact with your app. For example a LiveView component. That is possible with the built in..