<!-- LLM_VERSION_INFO
FORMAT: text/markdown
CONTENT_TYPE: article
ORIGINAL_URL: https://fly.io/phoenix-files/saving-and-restoring-liveview-state
ALTERNATE_VERSION: phoenix-files/saving-and-restoring-liveview-state/index.html (text/html)
EXTRACTION_DATE: 2026-04-18T22:26:16.474Z

This is the markdown version with text-only content (images converted to alt-text).
For rich formatting with images, request the HTML version at: phoenix-files/saving-and-restoring-liveview-state/index.html
-->

# Saving and Restoring Phoenix LiveView State

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](/content/docs/speedrun/ ""/index.html); 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](/content/blog/creating-the-livebook-launcher-in-liveview/ ""/index.html), 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.

- [`Phoenix.Token.encrypt/4`](https://hexdocs.pm/phoenix/Phoenix.Token.html#encrypt/4 "") - Encrypt the data before sending it to the client.
- [`Phoenix.Token.decrypt/4`](https://hexdocs.pm/phoenix/Phoenix.Token.html#decrypt/4 "") - Decrypt and validate the data from the client.

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](https://hexdocs.pm/phoenix_live_view/js-interop.html#client-hooks-via-phx-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`.

```javascript
// 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 set up. 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.

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

- [`mount/3`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#c:mount/3 "")
- [`handle_params/3`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#c:handle_params/3 "")

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 fire 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](/content/launch/livebook/ ""/index.html). 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.

Our LiveView can only push events down to the browser once it’s upgraded to a websocket connection. We will use the [`connected?/1`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#connected?/1 "") function call to know when it’s ready.

```elixir
@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.

```elixir
# 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!

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

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

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

```javascript
// ...
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".`

```html
<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](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage "") for storing data in the browser. Another option is [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/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](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage "")

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.

## Combined LiveView Code

```elixir
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"]

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

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