Tutorial

Share LiveView state between tabs

This post was updated 01 May - 2020

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 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

  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
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:

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

Related Tutorials

Published 29 Jan - 2020
Updated 01 May - 2020

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

Published 02 May - 2022

LiveView and page specific javascript

In most applications you have some page specific javascript that is only used in one or just a few pages. The solution for this is to either setup..