Skip to main content
LiveView provides comprehensive form bindings for handling user input, validation, and submission with minimal client-side code.

Basic Form Setup

Use phx-change and phx-submit to handle form events:
<.form for={@form} id="my-form" phx-change="validate" phx-submit="save">
  <.input type="text" field={@form[:username]} />
  <.input type="email" field={@form[:email]} />
  <button>Save</button>
</.form>
.form is defined in Phoenix.Component.form/1. The @form assign is created from a changeset via Phoenix.Component.to_form/1.

Handling Form Events

def mount(_params, _session, socket) do
  {:ok, assign(socket, form: to_form(Accounts.change_user(%User{})))}
end

Individual Input Events

Target specific inputs with their own change events:
<.form for={@form} id="my-form" phx-change="validate" phx-submit="save">
  <.input field={@form[:email]} 
          phx-change="email_changed" 
          phx-target={@myself} />
</.form>
def handle_event("email_changed", %{"user" => %{"email" => email}}, socket) do
  # Handle email change
  {:noreply, socket}
end
  1. Only the individual input is sent in params for inputs with phx-change
  2. Inputs with phx-change must still be within a form element

Error Feedback

LiveView tracks which inputs have been interacted with using _unused_ parameters:
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
  errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []

  assigns
  |> assign(field: nil, id: assigns.id || field.id)
  |> assign(:errors, Enum.map(errors, &translate_error(&1)))
  # ... render input with errors
end
Disable sending _unused parameters by adding phx-no-unused-field to inputs or forms.

Special Input Types

Number Inputs

Number inputs have special handling—LiveView won’t send change events when invalid:
<input type="number">
Number inputs have accessibility issues and convert large numbers to exponential notation. Consider using inputmode instead:
<input type="text" inputmode="numeric" pattern="[0-9]*">

Password Inputs

Password fields require explicit value setting for security:
<.input field={f[:password]} value={input_value(f[:password].value)} />
<.input field={f[:password_confirmation]} 
        value={input_value(f[:password_confirmation].value)} />

Nested Inputs

Handle nested associations with .inputs_for:
<.inputs_for :let={fp} field={f[:friends]}>
  <.input field={fp[:name]} type="text" />
</.inputs_for>

File Inputs

LiveView supports reactive file uploads with drag-and-drop:
<div class="container" phx-drop-target={@uploads.avatar.ref}>
  <.live_file_input upload={@uploads.avatar} />
</div>
See the Uploads guide for details.

Rate Limiting

Control validation frequency with debounce:
Validate when field loses focus:
<input type="text" name="user[email]" phx-debounce="blur"/>

Form Recovery

Forms automatically recover input values after reconnection or crashes:
<form id="wizard" phx-change="validate_wizard_step" phx-submit="save">
  <!-- Form fields -->
</form>

Custom Recovery

For stateful forms, provide a custom recovery event:
<form id="wizard" 
      phx-change="validate_wizard_step" 
      phx-auto-recover="recover_wizard">
  <!-- Form fields -->
</form>
def handle_event("validate_wizard_step", params, socket) do
  # Regular validation for current step
  {:noreply, socket}
end

def handle_event("recover_wizard", params, socket) do
  # Rebuild state based on all input data
  {:noreply, socket}
end
Disable automatic recovery with phx-auto-recover="ignore".
Disable LiveReload in development (code_reloader: false) to test form recovery properly.

Resetting Forms

Use standard reset buttons:
<form id="my-form" phx-change="changed">
  <input type="text" name="search" />
  <button type="reset" name="reset">Reset</button>
</form>
def handle_event("changed", %{"_target" => ["reset"]} = params, socket) do
  # Handle form reset
  {:noreply, socket}
end

def handle_event("changed", params, socket) do
  # Handle regular form change
  {:noreply, socket}
end

Submitting to HTTP Endpoints

Trigger standard HTTP form submission with phx-trigger-action:
<.form :let={f} for={@changeset}
  action={~p"/users/reset_password"}
  phx-submit="save"
  phx-trigger-action={@trigger_submit}>
  <!-- Form fields -->
</.form>
def handle_event("save", params, socket) do
  case validate_change_password(socket.assigns.user, params) do
    {:ok, changeset} ->
      {:noreply, assign(socket, changeset: changeset, trigger_submit: true)}

    {:error, changeset} ->
      {:noreply, assign(socket, changeset: changeset)}
  end
end
When phx-trigger-action is true, LiveView disconnects and submits the form to the specified action.

Client Behavior

During phx-change

  • Input and form receive phx-change-loading CSS class
  • JavaScript client is the source of truth for focused input values
  • Server receives "_target" param with the triggering input’s keyspace
# For input: <input name="user[username]"/>
%{"_target" => ["user", "username"], "user" => %{"username" => "Name"}}

During phx-submit

  1. Form inputs set to readonly
  2. Submit button disabled
  3. Form receives phx-submit-loading class

Loading States

phx-disable-with

Change button text during submission:
<button type="submit" phx-disable-with="Saving...">Save</button>
phx-disable-with uses innerText, so nested elements like SVG icons won’t be preserved.

CSS Loading States

Use CSS to show/hide content during submission:
.while-submitting { display: none; }
.inputs { display: block; }

.phx-submit-loading .while-submitting { display: block; }
.phx-submit-loading .inputs { display: none; }
<form id="my-form" phx-change="update">
  <div class="while-submitting">Please wait while we save...</div>
  <div class="inputs">
    <input type="text" name="text" value={@text}>
  </div>
</form>
Always include a unique HTML id on forms to prevent focus loss when DOM siblings change.

Triggering Events from JavaScript

Dispatch form events programmatically:
document.getElementById("my-select").dispatchEvent(
  new Event("input", {bubbles: true})
)

Preventing Form Submission

Prevent submission with a hook:
let Hooks = {}
Hooks.CustomFormSubmission = {
  mounted() {
    this.el.addEventListener("submit", (event) => {
      if (!this.shouldSubmit()) {
        event.stopPropagation()
        event.preventDefault()
      }
    })
  },
  shouldSubmit() {
    // Custom validation logic
    return true
  }
}
<form id="my-form" phx-hook="CustomFormSubmission">
  <input type="text" name="text" value={@text}>
</form>

See Also

Build docs developers (and LLMs) love