Saving and Restoring LiveView State

Image by Annie Ruygt

Fly.io runs apps close to users, by transmuting Docker containers into micro-VMs that run on our own hardware around the world. This is a post about how you can save and restore Phoenix LiveView state to the browser. If you are interested in deploying your own Elixir project, the easiest way to learn more is to try it out; you can be up and running in just minutes.

There are multiple ways to save and restore state for your LiveView processes. You can use an external cache like Redis, your database, or even the browser itself. Sometimes there are situations, like I described previously, where you either can’t or don’t want to store the state on the server. In situations like that, you have the option of storing the state in the user’s browser. This post explains how you can use the browser to store state and how your LiveView process can get it back later. We’ll go through the code so you can add something similar to your own project.

Saving Phoenix LiveView state to the browser is a bit like taking your brain, packaging it up, encrypting and signing it, and sending it to the browser for storage. Then asking for it back again later when you need it!

What to Store

First, we need to talk about what we want to store. There may be a lot of different kinds of state being tracked in your LiveView. It may even include UI centric things like which tab is active or if a region is expanded. It doesn’t make sense to store state like that. In fact, you might have some state that could cause problems if it was restored later into a new LiveView! So think about what values are actually important to save and restore.

Dont’s:

  • Don’t store things that can easily be reconstructed from the database.
  • Don’t store UI related state.
  • Don’t store things you don’t actually need.

Do’s:

  • Gather up all the data you want saved into a single data structure.
  • Use a map or struct.

For example, in the Livebook Launcher, the data I’m saving is:

  • Deployment Step - Last known step in the deployment process.
  • Version - The version of the data structure.
  • Deployment ID - The ID of the deployment being tracked.
  • App URL - The URL the for the finished deployed app when it’s done.
  • Private Values - Like the Livebook’s password.

TIP: Include a Version Number

If we ever need to change the data structure stored in the browser, then a newly deployed LiveView may restore an old version that is out of date for what the app expects! This can cause nasty bugs or break the ability to restore the data at all.

A handy tip is to include a version number in our token. Then, if we ever need to create a breaking change, it gives us the option of detecting the older version and converting or migrating it to something we can still work with.

Storing it Securely

We are talking about storing the internal state of a LiveView in the user’s browser. We should never blindly trust user input! So we need a way to keep our data both tamper proof and hidden as well. Fortunately, Phoenix has some built-in tools we can use for this.

The decrypt/4 function takes the option :max_age which we’ll use. This verifies the token only if it has been generated “max age” ago in seconds. This prevents us from having to deal with decrypting state from a month ago. Handy!

Simple Hook

In order to save and restore our LiveView state from the server to the browser and back, we need a simple JS hook. This gives us two-way communication between the server and browser. We will make the hook be general purpose so it could work for multiple LiveView pages in our app if needed.

This file knows how to read from and write to the browser’s sessionStorage. Nothing in this file is explicit to our application or our usage, so it can be reused in multiple places if needed. Even the event name it pushes to the server is customized at the server. The obj.event value is provided by the server and is also handled by the server.

Let’s go over the hook now.

File: assets/js/hooks/local_state_store.js.

// JS Hook for storing some state in sessionStorage in the browser.
// The server requests stored data and clears it when requested.
export const hooks = {
  mounted() {
    this.handleEvent("store", (obj) => this.store(obj))
    this.handleEvent("clear", (obj) => this.clear(obj))
    this.handleEvent("restore", (obj) => this.restore(obj))
  },

  store(obj) {
    sessionStorage.setItem(obj.key, obj.data)
  },

  restore(obj) {
    var data = sessionStorage.getItem(obj.key)
    this.pushEvent(obj.event, data)
  },

  clear(obj) {
    sessionStorage.removeItem(obj.key)
  }
}

The mounted function is executed in the browser when the hook is setup. Here, it registers that our hook code handles events called store, clear, and restore. When the server pushes one of those events, it executes our function on the Javascript object.

The only “sending” that the hook does is in the restore function. When the server asks the client to restore state, the client gets any data under key stored in sessionStorage. Then it does a pushEvent("restoreSettings", data) back to the server with the loaded data.

What if there is nothing to load? We’ll worry about that at the server where pattern matching makes coding fun. I’m intentionally keeping this code very simple. It just tries to get whatever is asked for and push it to the server.

If this is hard to visualize or follow, we’ll cover it again from the server perspective below and it includes a chart to help piece the flow together.

Our LiveView

Most of the logic and code is in the LiveView and that’s where we’ll focus now.

Restoring on Demand

There are two places in your LiveView where you can request to restore the state during the startup process.

The mount callback executes first followed by handle_params. They are both called in the following situations:

  • User visits the LiveView for the first time
  • User reloads the page while on the LiveView
  • Server deploys while user is on your LiveView. The process is killed at the server during the deploy.

Actually, both callbacks fires twice when a LiveView is visited. Once to do the initial “dead” page load and then again when upgrading to the live socket.

Which Should I Use?

When the state you want to restore is not related to the URL or any query parameters, then mount/3 is the better choice. This may be the case when the stateful data is linked to something in the session like a user_id or something similar.

When the state you want to restore is connected to an ID or other data in the URL, then handle_params/3 is the better choice. That was the case for the Fly.io Livebook Launcher. The livebook portion of the URL is a parameter and all the data we want to store is linked to it that. So using handle_params/3 is the better choice in that situation.

Requesting the Saved State

For this example, I’m using handle_params/3. Just note that most of the code inside the function would be the same if I used the mount/3 function instead.

Check out this chart to help follow the flow between the server and client.

Restoring state flow

Our LiveView can only push events down to the browser once it’s upgraded to a websocket connection. We will use the connected?/1 function call to know when it’s ready.

  @impl true
  def handle_params(params, _, socket) do
    # Only try to talk to the client when the websocket
    # is setup. Not on the initial "static" render.
    new_socket =
      if connected?(socket) do
        # This represents some meaningful key to your LiveView that you can
        # store and restore state using. Perhaps an ID from the page
        # the user is visiting?
        my_storage_key = "a-relevant-value-from-somewhere"
        # For handle_params, it could be
        # my_storage_key = params["id"]

        socket
        |> assign(:my_storage_key, my_storage_key)
        # request the browser to restore any state it has for this key.
        |> push_event("restore", %{key: my_storage_key, event: "restoreSettings"})
      else
        socket
      end

    {:noreply, new_socket}
  end

The Elixir code fires the restore event in the JS hook. In the data passed to the client, we tell it which event to fire on the server. The browser client reads any state it has stored under the requested key and sends it to the server using a JS pushEvent function.

Receiving Stored State

When requested, the client sends whatever value it finds stored under the key to the server. If this is the first time the user has seen this page, the value found will be a javascript null. Rather than put logic into the javascript for how to handle that, we keep it simple and send it to the server anyway.

We expect the token_data to be a string. Using pattern matching, we try to parse the token. If the value wasn’t a string then it is handled by the second handle_event("restoreSettings", ...) function body and ignored.

  # Pushed from JS hook. Server requests it to send up any
  # stored settings for the key.
  def handle_event("restoreSettings", token_data, socket) when is_binary(token_data) do
    socket =
      case restore_from_token(token_data) do
        {:ok, nil} ->
          # do nothing with the previous state
          socket

        {:ok, restored} ->
          socket
          |> assign(:state, restored)

        {:error, reason} ->
          # We don't continue checking. Display error.
          # Clear the token so it doesn't keep showing an error.
          socket
          |> put_flash(:error, reason)
          |> clear_browser_storage()
      end

    {:noreply, socket}
  end

  def handle_event("restoreSettings", _token_data, socket) do
    # No expected token data received from the client
    Logger.debug("No LiveView SessionStorage state to restore")
    {:noreply, socket}
  end

When the server receives the encrypted token, it parses and validates it. Then we set the state using assign(socket, :state, restored). Now our LiveView has restored the previously saved state!

The function restore_from_token is pretty simple and is included at the end.

Storing the State

As the user interacts with the LiveView, we may reach a point when we want to save some important data to the browser. Usually some event triggers when this happens. It could be a “Start Game”, “Save” or “Deploy” button click. In this example, I’ll use a handle_event that is stripped down to only include parts needed for saving.

  def handle_event("something_happened_and_i_want_to_store", params, socket) do
    # This represents the special state you want to store. It may come from the
    # socket.assigns. It's specific to your LiveView.
    state_to_store = socket.assigns.state

    socket =
      socket
      |> push_event("store", %{
        key: socket.assigns.my_storage_key,
        data: serialize_to_token(state_to_store)
      })

    {:noreply, socket}
  end

It executes push_event("store", ...) which sends the data to the JS hook running in the browser. The browser writes the encrypted string to the SessionStorage.

The function serialize_to_token is pretty simple and is included at the end.

Other Files

For completeness, there are a few other files needed to make this all work.

We need to include and load the hook file in the project’s app.js file. There are multiple ways to do this, here’s an example:

File: assets/js/app.js.

// ...
import * as LocalStateStore from "./hooks/local_state_store"

let Hooks = {}

Hooks.LocalStateStore = LocalStateStore.hooks
// ...

In the LiveView template we need to link our JS hook to some part of the DOM. Since this hook doesn’t do anything visible, we can hook it to the page’s container. In my case, it’s a main tag. Adapt this to fit your page. The important part is phx-hook="LocalStateStore".

<main class="..." id="..." phx-hook="LocalStateStore">
  <!-- other page content -->
</main>

With a JS hook defined, included in app.js and linked to our LiveView’s template, we are ready to go!

Closing

You now have all the tools needed to securely store and restore important LiveView state using the user’s browser to keep it!

The questions for you to consider:

  • Should you use mount or handle_params?
  • What key should the values be stored under?
  • What values are actually important to store and restore?
  • How should you structure the data? A map? A struct?
  • Does SessionStorage or LocalStorage make more sense?

localStorage vs sessionStorage

In this example I chose to use sessionStorage for storing data in the browser. Another option is localStorage. They both store the same data using the same API. So it’s trivial to switch from one to the other.

> sessionStorage is similar to localStorage; the difference is that while data in localStorage doesn’t expire, data in sessionStorage is cleared when the page session ends. ~MDN Docs

For my purposes, using sessionStorage automatically removes any sensitive data when the user closes the tab. Choose the option that works best for your situation.

The bodies of serialize_to_token, restore_from_token and clear_browser_storage are include in the full code sample below.

Combined LiveView Code

defmodule MyAppWeb.MyView do
  use MyAppWeb, :live_view
  alias Fly.DeployStateSerialize

  require Logger

  @impl true
  def handle_params(params, _, socket) do
    # Only try to talk to the client when the websocket
    # is setup. Not on the initial "static" render.
    new_socket =
      if connected?(socket) do
        # This represents some meaningful key to your LiveView that you can
        # store and restore state using. Perhaps an ID from the page
        # the user is visiting?
        my_storage_key = "a-relevant-value-from-somewhere"
        # For handle_params, it could be
        # my_storage_key = params["id"]

        socket
        |> assign(:my_storage_key, my_storage_key)
        # request the browser to restore any state it has for this key.
        |> push_event("restore", %{key: my_storage_key, event: "restoreSettings"})
      else
        socket
      end

    {:noreply, new_socket}
  end

  defp restore_from_token(nil), do: {:ok, nil}

  defp restore_from_token(token) do
    salt = Application.get_env(:my_app, MyAppWeb.Endpoint)[:live_view][:signing_salt]
    # Max age is 1 day. 86,400 seconds
    case Phoenix.Token.decrypt(MyAppWeb.Endpoint, salt, token, max_age: 86_400) do
      {:ok, data} ->
        {:ok, data}

      {:error, reason} ->
        # handles `:invalid`, `:expired` and possibly other things?
        {:error, "Failed to restore previous state. Reason: #{inspect(reason)}."}
    end
  end

  defp serialize_to_token(state_data) do
    salt = Application.get_env(:my_app, MyAppWeb.Endpoint)[:live_view][:signing_salt]
    Phoenix.Token.encrypt(MyAppWeb.Endpoint, salt, state_data)
  end

  # Push a websocket event down to the browser's JS hook.
  # Clear any settings for the current my_storage_key.
  defp clear_browser_storage(socket) do
    push_event(socket, "clear", %{key: socket.assigns.my_storage_key})
  end

  @impl true
  # Pushed from JS hook. Server requests it to send up any
  # stored settings for the key.
  def handle_event("restoreSettings", token_data, socket) when is_binary(token_data) do
    socket =
      case restore_from_token(token_data) do
        {:ok, nil} ->
          # do nothing with the previous state
          socket

        {:ok, restored} ->
          socket
          |> assign(:state, restored)

        {:error, reason} ->
          # We don't continue checking. Display error.
          # Clear the token so it doesn't keep showing an error.
          socket
          |> put_flash(:error, reason)
          |> clear_browser_storage()
      end

    {:noreply, socket}
  end

  def handle_event("restoreSettings", _token_data, socket) do
    # No expected token data received from the client
    Logger.debug("No LiveView SessionStorage state to restore")
    {:noreply, socket}
  end

  def handle_event("something_happened_and_i_want_to_store", params, socket) do
    # This represents the special state you want to store. It may come from the
    # socket.assigns. It's specific to your LiveView.
    state_to_store = socket.assigns.state

    socket =
      socket
      |> push_event("store", %{
        key: socket.assigns.my_storage_key,
        data: serialize_to_token(state_to_store)
      })

    {:noreply, socket}
  end
end