Forms and Validation

Copy Markdown View Source

LiveSvelte provides useLiveForm for building reactive forms backed by Ecto changesets with server-side validation.

Quick Example

LiveView:

defmodule MyAppWeb.UserFormLive do
  use MyAppWeb, :live_view
  alias MyApp.Accounts

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

  def handle_event("validate", %{"user" => params}, socket) do
    form = params |> Accounts.change_user() |> Map.put(:action, :validate) |> to_form()
    {:noreply, assign(socket, form: form)}
  end

  def handle_event("submit", %{"user" => params}, socket) do
    case Accounts.create_user(params) do
      {:ok, _user} -> {:noreply, push_navigate(socket, to: "/")}
      {:error, changeset} -> {:noreply, assign(socket, form: to_form(changeset))}
    end
  end

  def render(assigns) do
    ~H"""
    <.svelte name="UserForm" props={%{form: @form}} socket={@socket} />
    """
  end
end

Svelte Component:

<!-- assets/svelte/UserForm.svelte -->
<script>
  import { useLiveForm } from "live_svelte"

  let { form } = $props()

  const { field } = useLiveForm(() => form)
</script>

<form phx-submit="submit" phx-change="validate">
  <div>
    <label>Name</label>
    <input {...field("name")} />
    {#if field("name").error}
      <span class="error">{field("name").error}</span>
    {/if}
  </div>

  <div>
    <label>Email</label>
    <input type="email" {...field("email")} />
    {#if field("email").error}
      <span class="error">{field("email").error}</span>
    {/if}
  </div>

  <button type="submit">Save</button>
</form>

The useLiveForm Composable

import { useLiveForm } from "live_svelte"

const { field, fieldArray } = useLiveForm(() => form, options?)

The first argument is a getter function (not the form value directly). This ensures useLiveForm always reads the latest reactive prop value.

Options

type FormOptions = {
  changeEvent?: string      // Event name for validation (default: "validate")
  submitEvent?: string      // Event name for submission (default: "submit")
  debounceInMilliseconds?: number  // Debounce delay for change events (default: 300)
}

The field() Function

field(name) returns an object you can spread onto an <input> element:

<input {...field("email")} />

It returns:

  • name — the HTML input name (matches changeset field)
  • value — current field value from the changeset
  • error — error message string (or null)
  • phx-debounce — debounce attribute for change events

You can also access properties individually:

<input
  name={field("email").name}
  value={field("email").value}
  class={field("email").error ? "border-red-500" : ""}
/>
{#if field("email").error}
  <p>{field("email").error}</p>
{/if}

Nested Fields

Access nested fields with dot notation:

<input {...field("address.street")} />
<input {...field("address.city")} />

Dynamic Arrays with fieldArray()

For embeds_many or has_many with nested forms:

<script>
  import { useLiveForm } from "live_svelte"

  let { form } = $props()
  const { field, fieldArray } = useLiveForm(() => form)

  const skills = fieldArray("skills")
</script>

{#each skills.fields as skillField, i}
  <div>
    <input {...field(`skills.${i}.name`)} />
    <button type="button" onclick={() => skills.remove(i)}>Remove</button>
  </div>
{/each}

<button type="button" onclick={() => skills.append({ name: "" })}>Add Skill</button>

fieldArray(path) returns:

  • fields — reactive array of field descriptors
  • append(value) — add an item to the end
  • prepend(value) — add an item to the start
  • remove(index) — remove an item by index

Encoding Changesets as Props

To pass a changeset form as props, use LiveSvelte.Encoder for the changeset data. Phoenix's to_form/1 produces a Phoenix.HTML.Form struct that LiveSvelte can encode automatically.

For custom structs used inside the form data, use @derive:

defmodule MyApp.Address do
  @derive {LiveSvelte.Encoder, only: [:street, :city, :zip]}
  embedded_schema do
    field :street, :string
    field :city, :string
    field :zip, :string
  end
end

TypeScript Types

import type { Form } from "live_svelte"

// Type your component props
let { form }: { form: Form<{ name: string; email: string }> } = $props()

const { field } = useLiveForm(() => form)
// field("name").value is typed as string

Gettext Integration

If you have a Gettext backend configured, LiveSvelte translates error messages automatically:

# config/config.exs
config :live_svelte, gettext_backend: MyAppWeb.Gettext

Error messages from changesets will use your Gettext translations.

Full Form Example with Validation

# LiveView
def handle_event("validate", %{"user" => params}, socket) do
  changeset =
    %User{}
    |> User.changeset(params)
    |> Map.put(:action, :validate)

  {:noreply, assign(socket, form: to_form(changeset))}
end

Setting action: :validate on the changeset causes Ecto to include validation errors, which LiveSvelte then passes back to the field().error values in the component.