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 assign
ing "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:
- Developer changes
assigns
. - LiveView calculates the minimum HTML Diff changes and sends it down the WebSocket.
- LiveView.js sends the changes to the virtual DOM library
morphdom
which alters the HTML DOM. - 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:
- User changes the text box, firing a
phx-change
event. - Handle event
assign
changes the LiveView state. - LiveView calculate s a diff that is essentially
set input.value to assigns.query
- LiveView.js and
morphdom
sets the HTML DOM value of our input. - 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:
- On re-connect a
phx-change
event is fired with the current DOM for every Form that has the sameid
as a reconnect mounts html. - Handle event
assign
changes the LiveView internal state with the validate params. - LiveView calculate s a diff that is essentially
set input.value to assigns.query
. - LiveView.js and
morphdom
sets the HTML DOM value of our text box. - 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
- Always give each Form a unique and consistent
id
. - Always have a
phx-change
event for Forms that updates the server sideassign
.
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!