Live Form Validation in Phoenix LiveView Without Writing JavaScript
Phoenix LiveView lets Ecto changesets power real-time form validation over a WebSocket — no client-side JavaScript. A worked registration form example, plus latency and state trade-offs.
07 Mar 2026, 12:31 UTC

The problem: validation that doesn't wait for submit
Users expect forms to complain the moment they type something wrong — a taken username, a malformed email, a password that's too short. In a traditional server-rendered Phoenix app, that feedback only arrives after a full form POST and re-render, which means either a frustrating submit-and-pray loop or a pile of hand-written JavaScript duplicating your Ecto changeset rules on the client.
Phoenix LiveView removes that trade-off. Because the page holds a persistent WebSocket to a server process, every keystroke (debounced) can trigger your real Ecto changeset and push back only the changed error messages. One source of truth for validation, zero client-side code. This post walks through how that actually works, with a concrete registration form example for Phoenix 1.7+ and LiveView 0.19+.
How the round trip stays cheap
A LiveView is a GenServer-like process holding your page state in socket.assigns. The browser sends events like phx-change as small WebSocket frames; the server re-renders the template, diffs it against the previous render, and pushes only the minimal patches back. HEEx change tracking means a form with one invalid field re-renders just that field's error tag — not the whole page.
Two implications matter for forms:
- Validation is your normal changeset.
phx-changefires on input, you runAccounts.change_user/2, and assign the resulting changeset. Errors render with the standard form helpers. - Every keystroke is a server round trip. On a fast connection this is invisible; over 150 ms RTT, typing can feel sluggish. Mitigations come later.
A worked example: live-validated registration form
Assume a fresh app generated with mix phx.new demo --live (Phoenix 1.7, LiveView 0.19.x — confirm with mix hex.info phoenix_live_view). In your context, expose a changeset function:
# lib/demo/accounts.ex
def change_user(%User{} = user, attrs \\ %{}) do
User.registration_changeset(user, attrs)
endThe LiveView handles both validation and submission:
defmodule DemoWeb.RegistrationLive do
use DemoWeb, :live_view
alias Demo.Accounts
alias Demo.Accounts.User
def mount(_params, _session, socket) do
changeset = Accounts.change_user(%User{})
{:ok, assign_form(socket, changeset)}
end
def handle_event("validate", %{"user" => params}, socket) do
changeset =
%User{}
|> Accounts.change_user(params)
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
def handle_event("save", %{"user" => params}, socket) do
case Accounts.register_user(params) do
{:ok, _user} ->
{:noreply,
socket
|> put_flash(:info, "Account created")
|> push_navigate(to: ~p"/")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp assign_form(socket, changeset) do
assign(socket, form: to_form(changeset))
end
endThe Map.put(:action, :validate) line is the trick people miss: Ecto only surfaces errors on a changeset that has an action set, so without it your form stays silent during typing.
The HEEx template binds events to the form:
<.simple_form
for={@form}
id="registration-form"
phx-change="validate"
phx-submit="save"
>
<.input field={@form[:email]} type="email" label="Email" phx-debounce="300" />
<.input field={@form[:password]} type="password" label="Password" />
<:actions>
<.button>Create account</.button>
</:actions>
</.simple_form>The core-components <.input> helper renders changeset errors next to each field automatically. phx-debounce="300" on the email field batches keystrokes so you don't send an event per character — important for uniqueness checks that hit the database.
To verify it works: start the server with mix phx.server, open the page, and watch your browser's Network tab. You'll see the initial HTTP render, the WebSocket upgrade, and small phx frames on each change event. Type an invalid email and the error appears without a page load. You can also test without a browser using Phoenix.LiveViewTest: element(lv, "#registration-form") |> render_change(%{user: %{email: "bad"}}) then assert_has(lv, "p", text: "has invalid format").
Trade-offs worth knowing before you commit
Latency is real. Every interaction round-trips to the server. For users on high-latency or flaky mobile connections, live validation can feel worse than client-side checks. Use phx-debounce generously, and consider Phoenix.LiveView.JS commands for purely local interactions (toggling visibility, disabling buttons) that don't need the server.
Don't block the process. A slow uniqueness query inside handle_event("validate", ...) delays all patches for that user's session. Keep validation queries indexed and fast; offload anything heavy to a Task and push results back.
Authorization is still your job. Events are signed and CSRF-protected, but LiveView does not enforce permissions — validate them in handle_event exactly as you would in a controller.
Reconnects lose transient state. If the WebSocket drops (Wi-Fi to cellular switch), LiveView reconnects and re-mounts; anything not derivable from the session or params is gone. For multi-step forms, persist progress.
Where to go from here
Start with the generated scaffolding: mix phx.new demo --live gives you a working counter LiveView and an in-process test file to imitate. Convert one real form in an existing app — registration or settings is ideal — and measure the feel over a throttled connection in devtools. If typing stays snappy and your changeset stays the single source of truth, you've just deleted a whole category of client-side validation code.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.