Robust Session Storage in Phoenix Live View Sessions 9elements
Robust Session Storage in Phoenix Live View Sessions - 9elements #
Excerpt #
For an internal project called ‘ControlManiac’, which helps us with all the boring numbers juggling in our software studio, I had to add a global filter to the navigation bar, which lets me select the division I want to filter the data for on…
For an internal project called ‘ControlManiac’, which helps us with all the boring numbers juggling in our software studio, I had to add a global filter to the navigation bar, which lets me select the division I want to filter the data for on whatever view I am currently on.
Since we often switch views while using this app, it should keep its selection. It should also keep it upon reloading or revisiting the page and upon deployment of a new version.
This is what it looks like; it is the toggle element on the right:

The Home, Projects, and Costs navigation options are all separate live views, within the same live_session.
I thought there would be a quite easy solution, but it turned out that all common approaches had their downsides:
- Keeping it in the
assignsonly is not an option, since this is cleared upon changing the current live view. - Storing it in a session cookie would ensure it survives a full page reload but brings two problems: First, the session information is not reloaded upon changing the live views within the same
live_session, because this is done entirely via web sockets. Thus, we would get the initial value the session head at the time of the first HTTP request. This brings me to the second problem: I neither can update the session since there is no HTTP request upon live navigation, and the session cookie cannot be updated via web socket. - Storing it in an ETS table solves the problem of switching live views within the same
live_session, but of course, it would not survive an app restart, as is the case upon deployment of a new version.
I could, of course, pursue the session approach and just make sure to add a parameter every time I switch between the main live views, but this does feel very odd.
I eventually decided to go with a classic cookie-based session combined with an additional layer stored in an ETS table.
Preparing the pipeline #
To be able to load data via plug and on_mount, we first implement a module ControlManiacWeb.DivisionSelector and added it to the pipline:
elixirdefmodule ControlManiacWeb.Router do
use ControlManiacWeb, :router
pipeline :browser do
...
plug :fetch_current_division
end
...
scope "/", ControlManiacWeb do
pipe_through [...]
live_session :require_authenticated_user,
on_mount: [
...,
{ControlManiacWeb.DivisionSelector, :mount_current_division}
] do
...
end
end
...
fetch_current_divisionpug is added to the pipeline. Its task is to fetch the current division from cache or session and add it toconn.assigns.mount_current_divisionis used for the live views. Its task is to load the current division from cache and add it tosocket.assigns.
Storing settings in the ETS table #
Before we come to the implementation of these functions, lets have a look at how we implement the cache using an ETS table.
elixirdefmodule ControlManiac.SettingsCache do
use GenServer
@name __MODULE__
@tab :settings_cache
@ttl 60 * 60 * 24 * 31
# Client
def start_link(_), do: GenServer.start_link(__MODULE__, [], name: @name)
def insert(key, value) do
expiration = :os.system_time(:seconds) + @ttl
:ets.insert(@tab, {key, value, expiration})
end
def get(key, default \\ nil) do
lookup =
case :ets.lookup(@tab, key) do
[{_, value, _} | _] -> value
[] -> default
end
lookup
end
# Server
def init(_) do
:ets.new(
@tab,
[:set, :named_table, :public, read_concurrency: true, write_concurrency: true]
)
{:ok, []}
end
end
Retrieving data #
We now can use our SettingsCache together with normal sessions to implement the desired behavior for the pipeline. Please note that this implementation assumes we have a user present for whom we can store the data, and that we want to store the data on a per-user basis.
elixirdefmodule ControlManiacWeb.DivisionSelector do
import Plug.Conn
alias ControlManiac.Accounts.User
alias ControlManiac.SettingsCache
@default_division -1
@session_division_key "current_division_id"
@cache_division_key :division_cache
def fetch_current_division(conn, _opts) do
current_user = conn.assigns[:current_user]
assign(
conn,
:current_division,
get_division_from_cache(current_user) ||
get_division_from_session(conn, current_user) ||
@default_division
)
end
defp get_division_from_cache(nil), do: nil
defp get_division_from_cache(%User{id: user_id}) do
SettingsCache.get({@cache_division_key, user_id}, nil)
end
defp get_division_from_session(conn) do
get_session(conn, @session_division_key)
end
...
end
Handling data for live views #
So here comes the implementation of the on_mount/4 function used for live views:
elixirdef on_mount(:mount_current_division, _params, session, socket) do
division_id =
get_division_from_cache(socket.assigns[:current_user]) ||
get_division_from_session_map(session) ||
@default_division
{
:cont,
Phoenix.Component.assign(
socket,
:current_division,
division_id
)
}
end
We do this to handle a specific edge case: In case the server restarts and the socket connection is initiated again, we lost our cache. Upon reconnecting the socket there is no run through the plug pipeline (so, no fetch_current_division/2 invoked), but since reconnection is done via XHR request, we get updated session info. This is especially useful after deployments: When the system reconnects, we get the correct value from the session.
We need the fallback to session info anyway because of this edge case, so we can also use it as a fallback for the first requests, where the ETS table cache might not be warm although a division is set in the session. This way, we don’t have to make sure the cache is up to date in fetch_current_division/2: We use the value from the initial session, and as soon as the user changes the division, the ETS table cache entry is created and the fallback will never be reached.
Since in on_mount/4 we get the information stored in the session as a map, the function to retrieve it is slightly different:
elixirdefp get_division_from_session_map(session) do
if division_id = Map.get(session, @session_division_key) do
String.to_integer(division_id)
else
nil
end
end
So how do we make sure to store the most recent value in the session, given that by navigating only within a single live_session, we won’t have HTTP requests?
We do so by triggering a JS hook, which then performs an XHR request to store the actual data, an idea I copied from FullstackPhoenix.
We first implement a controller with the sole purpose of adding data to the session, given it is within a list of allowed keys:
elixirdefmodule ControlManiacWeb.StoreSessionController do
use ControlManiacWeb, :controller
@allowed_keys ~w(current_division_id)a
def create(conn, params) do
updated_conn =
Enum.reduce(params, conn, fn {key, value}, acc_conn ->
if String.to_atom(key) in @allowed_keys do
put_session(acc_conn, String.to_atom(key), value)
else
acc_conn
end
end)
send_resp(updated_conn, 200, "")
end
end
html<div id="store-session" phx-hook="StoreSession"></div>
js// store_session.js
export const StoreSession = {
mounted() {
const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content')
this.handleEvent('store_session', data => {
fetch('/store_session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token
},
body: JSON.stringify(data)
})
})
}
}
// And don't forger to register the hook in app.js
import { StoreSession } from "./store_session"
let liveSocket = new LiveSocket("/live", Socket, {
...
hooks: {
StoreSession: StoreSession
}
})
elixirdef handle_event("switch-division", %{"id" => id}, socket) do
DivisionSelector.update_division(
socket.assigns.current_user,
String.to_integer(id)
)
socket =
socket
|> push_event("store_session", DivisionSelector.map_for_session(id))
|> assign(:current_division, id)
{:noreply, socket}
end
elixirdef update_division(%User{id: user_id} = user, division_id) do
SettingsCache.insert({@cache_division_key, user_id}, division_id)
end
def map_for_session(id) do
%{@session_division_key => id}
end
In the real application, we also used Phoenix.PubSub to notify current views about the division change, which might be a topic for another blog post some time.
Conclusion #
Given that this is what I thought to be a very simple problem, I was quite surprised that I was not able to find an easier solution. But, on the other hand, handling global state is hard, no matter the framework, and at least within Elixir Phoenix, we are able to implement a solution in a very explicit and controllable way.