When Phoenix LiveView Replaces Your JavaScript Framework — and When It Shouldn't
Phoenix LiveView can delete your duplicated validation and state layer — for the right features. A worked form example, its test, and the line where LiveView stops fitting.
30 Jul 2026, 04:42 UTC

Interactive features in a Phoenix app often carry a hidden tax: validation rules and business logic live in Elixir, then get re-implemented in React or Vue so the UI can respond without a page reload. Two codebases, two state models, and a JSON API contract between them that drifts over time.
The thesis: for server-data-driven features, Phoenix LiveView removes that second implementation by holding UI state in a server-side process and pushing minimal HTML diffs over a WebSocket. This piece explains the model, works through a validated form end to end (including its test), and then draws the line where LiveView stops being the right tool.
Assumptions Before the Code
Examples target Phoenix 1.7+ with LiveView 0.18+ on Elixir 1.14+ and a current Erlang/OTP. These APIs have shifted across releases, so confirm against the docs for your exact versions. To follow along:
# Terminal; requires Elixir, Erlang, and the Phoenix installer (mix archive.install hex phx_new)
mix phx.new demo --live
cd demo
mix setup
The --live flag wires the WebSocket endpoint and generates core_components.ex, which provides the <.form> and <.input> components used below.
One Process Per Connected User
Each connected client gets a dedicated BEAM process holding socket.assigns — the data the template renders. An event (click, keystroke) travels over the WebSocket, handle_event/3 updates assigns, and the HEEx template re-renders only what changed. Because HEEx compiles templates into change-tracking functions, the server sends a small diff rather than a full page or a JSON payload the client must reconcile.
BEAM processes are cheap, but not free: the footprint scales with what you put in assigns. A form's worth of fields is trivial; a large list of structs is not. Any specific per-connection memory number you read (including in this article's research notes) is a starting assumption — load-test with realistic data before sizing nodes.
Worked Example: Validation in Exactly One Place
The concrete win: an Ecto changeset is the single source of validation truth, and the UI reflects it on every keystroke without any JavaScript you wrote.
The Template
<!-- lib/demo_web/live/user_live/form.ex (render) -->
<.form for={@form} phx-change="validate" phx-submit="save" phx-debounce="300">
<.input field={@form[:username]} type="text" label="Username" />
<button>Save</button>
</.form>
phx-debounce="300" caps change events at one per 300ms of typing, which matters on high-latency connections where every keystroke is a server round trip.
The Event Handler
def handle_event("validate", %{"user" => user_params}, socket) do
changeset =
%User{}
|> User.changeset(user_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(changeset))}
end
Setting :action makes the generated form components display errors even though nothing was submitted. Phoenix.Component.to_form/1 converts the changeset into the form struct the components expect.
Confirming It Actually Works
Open browser DevTools → Network → filter "WS". Typing should produce small outbound frames and compact "diff" replies; the page never reloads. If you instead see full HTML documents, the WebSocket upgrade failed — check for a proxy or load balancer blocking upgrades or killing idle connections.
For automated coverage, controller tests won't exercise this flow. Phoenix.LiveViewTest simulates the mount and event round trips in plain ExUnit:
import Phoenix.LiveViewTest
test "shows validation error for taken username", %{conn: conn} do
{:ok, view, _html} = live(conn, "/users/new")
html =
view
|> form("form", user: %{username: "taken_name"})
|> render_change()
assert html =~ "has already been taken"
end
Run with mix test --trace. render_change/1 invokes the same handle_event("validate", ...) the browser triggers, so the server-side logic is covered without a browser. (The error string depends on your changeset's validation message — adjust the assertion to match.)
Where the Model Breaks Down
Network dependency is structural, not incidental. Every interaction is a round trip. For CRUD apps on typical connections this is fine; for drawing canvases, drag-and-drop-heavy UIs, games, or anything that must work offline, LiveView is the wrong tool. Decide this per feature, not per project.
LiveView must own the DOM it renders. HEEx diffing assumes the server's view of the markup matches the browser's. Third-party JavaScript that mutates LiveView-managed nodes causes desync and forced remounts. Isolate such widgets:
<div id="sales-chart" phx-update="ignore" phx-hook="SalesChart"></div>
phx-update="ignore" tells the patcher to leave that subtree alone; a JS hook manages it instead.
Operational Checklist Before You Commit
- Authorization per event, not just per mount. The WebSocket is long-lived; a session valid at
mount/3may expire or lose permissions mid-session. Re-check authorization in sensitivehandle_eventcallbacks. - Multi-node delivery. PubSub broadcasts must reach the node holding a user's process. A connected BEAM cluster handles this via PG; if your platform doesn't cluster, use the Redis PubSub adapter, and consider sticky sessions at the load balancer.
- Telemetry. LiveView emits events such as
[:phoenix, :live_view, :mount, :stop]and[:phoenix, :live_view, :handle_event, :stop]with durations. Wire them intoTelemetry.Metrics; a risinghandle_eventduration usually means expensive per-keystroke database work that debouncing or query tuning should fix. - Proxy configuration. Corporate proxies and load balancers can block WebSocket upgrades or enforce idle timeouts; verify the upgrade path in your target environment early.
A Practical Starting Point
Pick one feature — a validated form or a live-filtered table — and rebuild it with mix phx.gen.live. Watch the WS frames to confirm diff payloads, cover the interaction with LiveViewTest, and measure memory and event latency under realistic concurrency before generalizing the pattern. Keep genuinely client-heavy features in JavaScript; the goal is deleting the duplicated layer, not winning an argument about frameworks.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.