Don't want to miss out on Elixir news? Subscribe to ElixirPulse!

The Top 3 LiveView Form Mistakes (And How to Fix Them)

John Curran

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

  1. Click “I have a referral code”, JS.show/1 reveals the input instantly (no server round-trip)
  2. Same click sets the hidden input’s value to "true" using JS.set_attribute/2
  3. Then dispatches an input event to the hidden input using JS.dispatch/2 - this is the key trick
  4. That input event triggers phx-change="validate" on the form
  5. Validation runs with the updated has_referral_code value
  6. The changeset logic just works, validate_referral_code_if_indicated/1 sees 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:

  1. Slow, laggy forms with scattered logic across multiple callbacks
  2. Brittle systems where UI and database are tightly coupled
  3. Users stuck with valid data in impossible-to-submit forms

The fixes:

  1. Keep form state on client, use JS commands + hidden inputs + dispatch, centralize logic in phx-change
  2. Decouple with embedded schemas, one schema per form, tailored to the UX
  3. Never use Map.put/Map.merge on changesets, always use Ecto.Changeset functions or recreate from scratch

Five years in, these patterns work. They’re not theoretical, they’re battle-tested at multiple companies.