How Phoenix LiveView Form Auto-Recovery works

Two birds holding a gun made out of feet.
Image by Annie Ruygt

We’re Fly.io and we transmute containers into VMs, running them on our hardware around the world. We have fast booting VM’s, so why not take advantage of them?

Phoenix LiveView is a server-side frontend framework and with this great power comes great responsibility on the developer. A very common source of frustration is around Forms, their assigns values and connection resets or deploys. In this post we’ll discuss how this all works and how it enables Form Auto-Recovery.

Let’s say we have this basic LiveView with a form and a query input:

defmodule FormExamples.FormsLive do
  use FormExamplesWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok,
      socket
      |> assign(form: to_form(%{"query" => nil}))
    }
  end

  def render(assigns) do
    ~H"""
    <.simple_form for={@form} phx-submit="save" id="form" class="mb-20">
      <.input field={@form[:query]} label="Search" />
    </.simple_form>
    """
  end

  def handle_event("save", %{"query" => q}, socket) do
    {:noreply,
      socket
      |> assign(form: to_form(%{"query" => nil}))
      |> put_flash(:info, "Form submitted with query: #{q}")}
  end
end

When we type into the text box and hit enter, our save callback is called and we reset the form. Before we move on, can you see the problem with this form?

On our Form submit we are assigning "query" => nil but "hello" remains in our text box. What’s going on here?

Spoiler: We did not change the assign value while we were typing the query, in LiveView’s view of the world the form‘s query value is nil, and it never changed. These implications will be discussed further in the article.

The Problem of State

LiveView and, any good frontend framework, help us organize our front end code around encapsulating state within a component. Meaning the only place we need to think about the state of a view is in our assigns, there will be no other references mutating our values outside of the component.

Unfortunately in all frameworks this a lie, or a helpful fiction. All frontend frameworks actually have to deal with three(!) separate local states and keep them in sync, for example here are the 3 states for LiveView:

  • Server, where we keep track of assigns, session and the WebSocket related state.
  • Browser HTML DOM state.
  • Browser Internal DOM state.

In react the first state would be the Component/state/props, they still need to maintain the HTML and the internal DOM State. This is why they have so many functions around use* now to encapsulate the many different ways you might want to manage state and the dom.

In LiveView flow of state is as follows:

  1. Developer changes assigns.
  2. LiveView calculates the minimum HTML Diff changes and sends it down the WebSocket.
  3. LiveView.js sends the changes to the virtual DOM library morphdom which alters the HTML DOM.
  4. User changes inputs, which only changes the Browser Internal DOM State.

Knowing this maybe we can guess what went wrong with our form, and it happens right at point 1, the assigns value for the query value never changed. No diff was calculated, the browser HTML never changed so the HTML Input value never changed, all the while the Browsers Internal DOM State value did change.

Here is how we can change our example to reset the value on submit:

defmodule FormExamples.FormsLive do
  use FormExamplesWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok,
      socket
      |> assign(form: to_form(%{"query" => nil}))
    }
  end

  def render(assigns) do
    ~H"""
    <.simple_form for={@form} phx-submit="save" phx-change="validate" id="form" class="mb-20">
      <.input field={@form[:query]} label="Search" />
    </.simple_form>
    """
  end

  def handle_event("validate", %{"query" => q}, socket) do
    {:noreply, assign(socket, form: to_form(%{"query" => q}))}
  end

  def handle_event("save", %{"query" => q}, socket) do
    {:noreply,
      socket
      |> assign(form: to_form(%{"query" => nil}))
      |> put_flash(:info, "Form submitted with query: #{q}")}
  end
end

Here we added a phx-change to our form, with handle_event function that changes the form and query assigns whenever the form changes. Now when we hit submit our input would reset to nil because every single keypress we informed LiveView of this change! We can follow the flow of state here:

  1. User changes the text box, firing a phx-change event.
  2. Handle event assign changes the LiveView state.
  3. LiveView calculate s a diff that is essentially set input.value to assigns.query
  4. LiveView.js and morphdom sets the HTML DOM value of our input.
  5. Which is the same as the current HTML Internal Dom value so no change happens that the user can see.

Enter A Server Restart

Now this is where the Auto-Recovery Magic happens when we deploy or the process restarts:

  1. On re-connect a phx-change event is fired with the current DOM for every Form that has the same id as a reconnect mounts html.
  2. Handle event assign changes the LiveView internal state with the validate params.
  3. LiveView calculate s a diff that is essentially set input.value to assigns.query.
  4. LiveView.js and morphdom sets the HTML DOM value of our text box.
  5. Which is the same as the current HTML Internal Dom value so no change happens that the user can see.

The only difference here a little bit of Magic that LiveView does when it knows it’s reconnecting to an existing DOM State. If the current DOM has a Form with the same id, instead of replacing it, it fires the change event which should alter the LiveView’s current assigns with the previous state! And there you have it—fully recoverable forms with no server-side state at all! This works assuming the Form’s id remains unchanged.

And if you don’t believe me I pushed a live example here with code here.

Takeways

  1. Always give each Form a unique and consistent id.
  2. Always have a phx-change event for Forms that updates the server side assign.

By understanding its inner workings of form handling and auto-recovery mechanisms, we can unlock the full potential of LiveView to create seamless user experiences. I encourage you to check on your own forms and inputs and make sure they have ids and phx-change events wired up to give your users a better user experience!

Fly.io ❤️ Elixir

Fly.io is a great way to run your Phoenix LiveView apps. It’s really easy to get started. You can be running in minutes.

Deploy a Phoenix app today!