Restore LiveView State on Startup

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 recipe about how you can optimize restoring Phoenix LiveView state that's stored in 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.

You are storing some LiveView state in the browser. You want to retrieve that saved state as early as possible to improve the user experience. How can you do that?

Problem

The approach in Saving and Restoring LiveView State waits for the LiveView to request the client to send the data. In your situation, you know you want the data, so why wait around to be asked for it? Can you have it automatically pushed from the client without being requested? Can you do it in a way that is reusable for other pages and other LiveViews and not just this one page?

Solution

When you already know you want data stored in the browser during the startup of your LiveView, there is a way to send it up when the socket connection is being setup.

The data could be written into the HTML of the page or stored in sessionStorage or localStorage. Regardless of where it's stored, you have a way to send it to the server from the client without being asked.

Assuming a freshly created Phoenix application, the app.js file will look something like this.

import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"

let csrfToken =
  document.querySelector("meta[name='csrf-token']")
  .getAttribute("content")
let liveSocket =
  new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})

// Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", info => topbar.show())
window.addEventListener("phx:page-loading-stop", info => topbar.hide())

// connect if there are any LiveViews on the page
liveSocket.connect()

// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000)  // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket

Let's look at this line in particular.

let liveSocket = new LiveSocket("/live", Socket, ...)

The params option to the LiveSocket constructor accepts a javascript closure (aka "function") which will be invoked on the client when generating the param value. We can change params to look something like this:

let params = () => {
  return {_csrf_token: csrfToken, restore: sessionStorage.getItem(...)}
}

let liveSocket = new LiveSocket("/live", Socket, {params: params})

This reads the data we want to send up from sessionStorage on the client. Then on the server you read the connect params in the mount callback:

  def mount(_params, _session, socket) do
    case get_connect_params(socket) do
      %{"restore" => token} -> ...

This works well enough and depending on your needs, you may be done!

However, the code in app.js get's loaded on every page load. If you have multiple LiveViews and they don't care about or even want the data stored on the client, then sending it up every time we connect the socket is a waste of resources! Especially if the payload is large.

Can we make it better by only sending it up when we want it? If multiple pages store data, then we might want to be selective about what data we send up. In short, can we make this more reusable within a single application?

If we add some custom DOM attributes to our template, the app.js file can look for it. If it's there, it can also look for what key the data is stored under and fetch that during the connect process.

Our html might look like this:

<div data-state-restore="true" data-session-key="my_special_key">
  ...
</div>

Here we added data-state-restore="true" and data-session-key="my-key". There's nothing special about these keys except that we'll look for their existence and act on it.

In app.js, we can update the param function to look for those keys.

let params = (node) => {
  var restoreNode =
    node && node.querySelector("div[data-state-restore='true']")
  if (restoreNode) {
    var key = restoreNode.getAttribute("data-session-key")
    return {_csrf_token: csrfToken, restore: sessionStorage.getItem(key)}
  }
  else {
    return {_csrf_token: csrfToken}
  }
}

If your LiveView does not include data-state-restore, then nothing extra happens. Your LiveView continues on as you'd expect. If it is present, it uses the key we provide to look up in sessionStorage for any data we have stored there. That data is sent along when the connection opens in the mount callback.

Sweet!

Discussion

Phoenix and LiveView already provide the hooks we need to make this feature work. The key is knowing that the new LiveSocket(...) params can be a closure that determines at runtime if and what to send when connecting to the server.

When you know you want to restore LiveView state during the startup process, then this approach makes that easy. We took the extra steps here to make it reusable so our multiple LiveViews can save and restore their own state, or restore no state at all!

Restoring state during page setup helps improve the user experience. State is restored faster, and we avoid the extra round-trip of requesting to restore the state.

Fly ❤️ Elixir

Fly is an awesome place to run your Elixir apps. It's really easy to get started. You can be running in minutes.

Deploy your Elixir app today!