The Top 3 LiveView Form Mistakes (And How to Fix Them)
I’ve been writing LiveView since 2020. In that time, I’ve seen the same three form mistakes at multiple companies. Here’s what they are and how to fix them.
1. Slow, laggy forms with scattered logic because form state gets stored in socket assigns and server round-trips get used for dynamic UI (conditional inputs, toggles), instead of keeping that state in hidden form inputs where it belongs.
2. Brittle system where UI and database can’t evolve independently because database schemas get used directly for forms, coupling persistence logic to presentation.
3. Users stuck with valid data but can’t submit because changesets get manually manipulated with Map.put or Map.merge instead of Ecto.Changeset functions, leaving stale errors behind.
The common thread: don’t fight the framework. Keep form state on the client, create embedded schemas for your forms, and use Ecto.Changeset functions to modify changesets.
Mistake #1: Slow, laggy forms with scattered logic
The Problem
Forms feel slow and laggy. Every button click, every toggle, every conditional field requires a round trip to the server. Users notice the delay, especially on slower connections.
Worse for developers: form logic gets scattered across multiple event handlers. There’s phx-change for validation, phx-click for toggles, phx-submit for saving. Each one modifies state in assigns. Each one is another place bugs can hide.
Real example I’ve seen at multiple companies: a sign-up form asks “Do you have a referral code?” Click yes, a text input appears.
Why This Happens
A phx-click handler toggles visibility of a conditional input by setting @show_code_input in socket assigns. Now form state is split in two places: the form inputs themselves, and this boolean in assigns.
Later, during validation, they need to be reconciled. Is the code input visible? Check assigns. What did the user enter? Check params. This state is part of the form, but it’s stored outside the form.
Here’s the typical pattern:
def handle_event("toggle_code", _params, socket) do
{:noreply, assign(socket, :show_code_input, !socket.assigns.show_code_input)}
end
<button phx-click="toggle_code">I have a referral code</button>
<.input :if={@show_code_input} field={@form[:referral_code]} value={@show_code_input} />
Every click hits the server. Feels slow. Form state is now split: inputs in the form, @show_code_input in assigns. Form state lives outside the form, then gets reconciled with the form params later.
The Solution
Keep all form state in the form itself. Use hidden inputs for dynamic UI state like “has referral code?” Use JS commands for instant client-side toggles, then let those hidden inputs dispatch to phx-change. All your form logic stays in one place: the validation handler. No reconciliation needed.
Here’s how to rewrite the conditional referral code with a single phx-change event handler and instant UI updates with Phoenix.LiveView.JS:
All validations occur in a single callback:
def mount(_params, _session, socket) do
changeset = FormData.changeset(%FormData{}, %{})
{:ok, assign(socket, :form, to_form(changeset))}
end
def handle_event("validate", %{"form_data" => form_params}, socket) do
changeset =
%FormData{}
|> FormData.changeset(form_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :form, to_form(changeset))}
end
All validations are neatly encapsulated in the embedded schema:
defmodule MyAppWeb.UserRegistrationLive.FormData do
embedded_schema do
field :email, :string
field :password, :string
field :has_referral_code, :boolean, default: false
field :referral_code, :string
end
def changeset(form_data, attrs) do
form_data
|> cast(attrs, [:email, :password, :has_referral_code, :referral_code])
|> validate_required([:email, :password])
|> validate_referral_code_if_indicated()
end
defp validate_referral_code_if_indicated(changeset) do
if get_field(changeset, :has_referral_code) do
changeset
|> validate_required([:referral_code], message: "Please enter your referral code")
|> validate_length(:referral_code, is: 8, message: "Referral code must be 8 characters")
else
changeset
end
end
end
All form state and interactions live in the template:
<.form for={@form} phx-change="validate" phx-submit="save">
<%!-- Hidden input tracks whether they have a code --%>
<.input type="hidden" field={@form[:has_referral_code]} />
<%!-- Pure client-side toggle --%>
<button
type="button"
phx-click={
JS.show(to: "##{@form[:referral_code].id}")
|> JS.set_attribute({"value", "true"}, to: "##{@form[:has_referral_code].id}")
|> JS.dispatch("input", to: "##{@form[:has_referral_code].id}")
}
>
I have a referral code
</button>
<%!-- Conditionally shown input --%>
<.input
field={@form[:referral_code]}
label="Referral Code"
class="hidden"
/>
</.form>
What’s Happening
-
Click “I have a referral code”,
JS.show/1reveals the input instantly (no server round-trip) -
Same click sets the hidden input’s value to
"true"usingJS.set_attribute/2 -
Then dispatches an
inputevent to the hidden input usingJS.dispatch/2- this is the key trick -
That
inputevent triggersphx-change="validate"on the form -
Validation runs with the updated
has_referral_codevalue -
The changeset logic just works,
validate_referral_code_if_indicated/1sees the boolean and validates
Instant visual feedback. Server-side validation. No latency. No duplicate logic. The form stays in control, the client is the source of truth, and the server validates what the client sends.
Why the separate boolean? Can’t the validation just check if the referral code field has text? No, because if the user enters some text, then clicks to hide the text box (because they don’t actually have a code), the changeset will think they still need to provide a valid referral code. Since the input is hidden, they’ll never see the error message, and the form will appear broken. The boolean tracks intent, not content.
Why This Works
This works because the changeset gets recreated from params every time. The has_referral_code boolean is part of the form params. When the “input” event gets dispatched, the validation handler receives the full, updated state.
All form logic lives in one place: the validate handler. No scattered phx-click callbacks. No reconciling state between client and server. The form state is captured in the params, the server validates what the client sends, just like HTTP forms.
Instant visual feedback for users. Clean, centralized validation logic for developers.
Mistake #2: Brittle system where UI and database can’t evolve independently
The Problem
The system is brittle. Database schema can’t change without breaking the UI. UI can’t improve without migrating the database.
Need to rename a column? Every form that uses that schema breaks. Want to add a virtual field for better UX? Now the database schema has UI concerns. Need a field that only exists for form validation but shouldn’t be persisted? Where does it go?
Error messages are generic and database-oriented. Users see “must be at least 8 characters” instead of “Password must be at least 8 characters for your security.”
Multi-step forms become awkward. Conditional fields fight against the schema structure. There’s constant tension between what the form needs and what the database provides.
Why This Happens
It seems obvious at first. The form creates a User, so use the User schema:
# Wrong - using the database schema
def mount(_params, _session, socket) do
changeset = Accounts.User.changeset(%Accounts.User{}, %{})
{:ok, assign(socket, :form, to_form(changeset))}
end
But databases and forms serve different purposes. Databases normalize data, enforce referential integrity, handle persistence. Forms collect user input, provide helpful errors, guide users through workflows.
When you couple them together, database constraints leak into your UI. Foreign keys, join tables, normalization, users don’t care about any of that.
The Solution
Create an embedded schema for each form. Decouple your UI from your database. One schema per form, tailored to what that specific view needs.
defmodule MyAppWeb.UserRegistrationLive.FormData do
use Ecto.Schema
import Ecto.Changeset
@primary_key false
embedded_schema do
field :email, :string
field :password, :string
field :password_confirmation, :string
field :terms_accepted, :boolean, default: false
end
def changeset(form_data, attrs) do
form_data
|> cast(attrs, [:email, :password, :password_confirmation, :terms_accepted])
|> validate_required([:email, :password, :password_confirmation])
|> validate_format(:email, ~r/@/, message: "Please enter a valid email address")
|> validate_length(:password, min: 12, message: "Password must be at least 12 characters for your security")
|> validate_confirmation(:password, message: "Passwords don't match")
|> validate_acceptance(:terms_accepted, message: "You must accept the terms to continue")
# Custom validation that doesn't belong in the database layer
|> validate_email_not_disposable()
end
defp validate_email_not_disposable(changeset) do
validate_change(changeset, :email, fn :email, email ->
if DisposableEmailChecker.is_disposable?(email) do
[email: "Please use a permanent email address"]
else
[]
end
end)
end
end
Now your LiveView uses this form schema:
def handle_event("save", %{"form_data" => form_params}, socket) do
%FormData{}
|> FormData.changeset(form_params)
|> Ecto.Changeset.apply_action(:insert)
|> case do
{:ok, form_data} ->
# Convert form data to domain params
user_params = Map.from_struct(form_data)
Accounts.register_user(user_params)
|> handle_registration_result(socket)
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
What You Get
View-specific validations tailored to the form. Better error messages that actually help users. Database changes don’t break your UI. UI changes don’t require migrations. Helper fields, computed values, multi-step logic, none of it pollutes your schema. You can test form logic independently from database logic.
Mistake #3: Users stuck with valid data but can’t submit
The Problem
Users are stuck. They’ve entered valid data. The form looks fine. But the submit button won’t work. They’re trapped in an impossible-to-submit state.
The changeset has valid data in changes, but valid? is still false. Old validation errors are stuck in the errors list even though the data is now correct.
This breaks the core promise of forms: if the data is valid, the form submits.
Why This Happens
Someone tries to modify the changeset on the server outside the normal validation flow. Instead of using Ecto.Changeset functions, they reach for Map.merge or Map.put to modify the changeset directly:
def handle_event("toggle_newsletter", _params, socket) do
# Wrong - modifying changeset with Map.merge
changeset = Map.merge(socket.assigns.changeset, %{
changes: %{subscribe_newsletter: true}
})
{:noreply, assign(socket, :changeset, changeset)}
end
This bypasses all of Ecto’s validation logic. Ecto’s Changeset functions like put_change/3 don’t just update fields, they recalculate errors and validity. Map.merge doesn’t.
Here’s what Ecto.Changeset.put_change/3 does:
def put_change(%Changeset{data: data, types: types} = changeset, key, value) do
type = Map.get(types, key)
{changes, errors, valid?} =
put_change(data, changeset.changes, changeset.errors, changeset.valid?, key, value, type)
%{changeset | changes: changes, errors: errors, valid?: valid?}
end
It updates changes, errors, AND valid? together. When you use Map.merge, you only update what you explicitly pass, leaving stale errors behind.
Here’s the flow that breaks the form:
Step 1: Mount - Empty form, stored in assigns
stored_changeset = Subscriber.changeset(%Subscriber{}, %{})
|> Map.put(:action, :validate)
# Stored changeset:
%Ecto.Changeset{
valid?: false,
errors: [email: {"can't be blank", [validation: :required]}],
changes: %{}
}
Step 2: Button click outside form
User clicks “Subscribe to newsletter” button. Code tries to update with Map.merge:
Map.merge(socket.assigns.changeset, %{
changes: %{subscribe_newsletter: true}
})
# Result:
%Ecto.Changeset{
valid?: false, # <- STILL INVALID!
errors: [email: {"can't be blank", [validation: :required]}], # <- STUCK!
changes: %{email: "john@example.com"} # <- Data is valid but errors remain
}
The form is broken. User has valid data but can’t submit because errors remain.
The right way:
%Subscriber{}
|> Subscriber.changeset(%{"email" => "john@example.com"})
|> Map.put(:action, :validate)
# Result:
%Ecto.Changeset{
valid?: true,
errors: [], # <- Clean!
changes: %{email: "john@example.com"}
}
Never use Map.merge or Map.put to modify changeset fields.
The Solution
Always recreate the changeset from scratch with the submitted params. Changesets were designed for stateless HTTP requests, treat them that way.
def handle_event("validate", %{"subscriber" => params}, socket) do
changeset =
%Subscriber{}
|> Subscriber.changeset(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :form, to_form(changeset))}
end
Your schema’s changeset/2 function already uses cast/3, validate_required/2, and other Changeset functions under the hood. Let it do its job. Fresh changeset every time, no stale state.
The Mental Model
All three mistakes come from the same misunderstanding: LiveView forms are stateful, but changesets should be treated as stateless transformations.
HTTP:
Request → Create changeset → Validate → Save or re-render → Done
LiveView:
Event → Create fresh changeset → Validate → Assign to socket → Repeat
Params are your state. The changeset is a pure function of those params. Let the form track state via its inputs (including hidden inputs). Recreate your changeset from those params on every interaction.
This gives you:
- No stale validation state
- Predictable, testable validation logic
- Clear separation between UI and database
- Rich, responsive UX without sacrificing server-side validation
- View-specific error messages and business rules
Summary
These three mistakes cause more pain than anything else I’ve seen in production LiveView apps.
The problems:
- Slow, laggy forms with scattered logic across multiple callbacks
- Brittle systems where UI and database are tightly coupled
- Users stuck with valid data in impossible-to-submit forms
The fixes:
-
Keep form state on client, use JS commands + hidden inputs + dispatch, centralize logic in
phx-change - Decouple with embedded schemas, one schema per form, tailored to the UX
-
Never use
Map.put/Map.mergeon changesets, always useEcto.Changesetfunctions or recreate from scratch
Five years in, these patterns work. They’re not theoretical, they’re battle-tested at multiple companies.