Skip to main content
This guide covers the useLiveForm composable for building complex forms with server-side validation, nested objects, and dynamic arrays in LiveVue.
New to LiveVue? Check out Basic usage for fundamental patterns before diving into forms.

Quick example

Here’s how a typical form setup looks with useLiveForm:
<script setup lang="ts">
import { useLiveForm, type Form } from 'live_vue'

type UserForm = {
  name: string
  email: string
  profile: {
    bio: string
    skills: string[]
  }
}

const props = defineProps<{ form: Form<UserForm> }>()

const form = useLiveForm(() => props.form, {
  changeEvent: 'validate',     // Send validation requests on changes
  submitEvent: 'submit',       // Event sent on form submission
  debounceInMiliseconds: 300   // Debounce validation requests
})

const nameField = form.field('name')
const emailField = form.field('email')
const bioField = form.field('profile.bio')
const skillsArray = form.fieldArray('profile.skills')
</script>

<template>
  <div>
    <!-- Basic field with validation -->
    <input
      v-bind="nameField.inputAttrs.value"
      :class="{ error: nameField.isTouched.value && nameField.errorMessage.value }"
    />
    <div v-if="nameField.errorMessage.value">
      {{ nameField.errorMessage.value }}
    </div>

    <!-- Array field with add/remove -->
    <div v-for="(skillField, index) in skillsArray.fields.value" :key="index">
      <input v-bind="skillField.inputAttrs.value" placeholder="Enter skill" />
      <button @click="skillsArray.remove(index)">Remove</button>
    </div>
    <button @click="skillsArray.add('')">Add Skill</button>

    <!-- Form actions -->
    <button @click="form.submit()" :disabled="!form.isValid.value">
      Submit
    </button>
  </div>
</template>

Why useLiveForm?

Traditional client-side forms present several challenges:
  • Validation synchronization between client and server
  • Complex state management for nested objects and arrays
  • Type safety for deeply nested form structures
  • Accessibility and proper ARIA attributes
  • User experience patterns like field states and error handling
The useLiveForm composable solves these problems by:
  • Providing seamless server-side validation with debouncing
  • Offering type-safe field access for complex structures
  • Managing all form state reactively (dirty, touched, valid)
  • Automatically generating proper input attributes and accessibility features
  • Handling nested objects and dynamic arrays with ease

Form API reference

useLiveForm()

Creates a reactive form instance with validation and state management.
function useLiveForm<T extends object>(
  form: MaybeRefOrGetter<Form<T>>,
  options?: FormOptions
): UseLiveFormReturn<T>
Parameters:
  • form - Reactive reference to the form data from LiveView (typically () => props.form)
  • options - Configuration object for form behavior
Options:
OptionTypeDefaultDescription
changeEventstring | nullnullEvent sent on field changes (set to null to disable validation events)
submitEventstring"submit"Event sent on form submission
debounceInMilisecondsnumber300Debounce delay for change events to reduce server load
prepareDatafunction(data) => dataTransform data before sending to server
Returns:
Form-level state:
PropertyTypeDescription
isValidRef<boolean>No validation errors exist
isDirtyRef<boolean>Form values differ from initial
isTouchedRef<boolean>At least one field has been interacted with
isValidatingReadonly<Ref<boolean>>Whether validation requests are in progress
submitCountReadonly<Ref<number>>Number of submission attempts
initialValuesReadonly<Ref<T>>Original form values for reset
Field factory functions:
MethodDescription
field(path, options?)Get a typed field instance for the given path
fieldArray(path)Get an array field instance for managing dynamic lists
Form actions:
MethodDescription
submit()Submit form to server (returns Promise)
reset()Reset to initial values and clear state

FormField

Individual form field with reactive state and helpers:
Reactive state:
PropertyTypeDescription
valueRef<T>Current field value
errorsReadonly<Ref<string[]>>Validation errors from server
errorMessageReadonly<Ref<string | undefined>>First error message
isValidRef<boolean>No validation errors
isDirtyRef<boolean>Value differs from initial
isTouchedRef<boolean>Field has been blurred
Input binding:
PropertyDescription
inputAttrsObject containing value, event handlers, name, id, and accessibility attributes. Use with v-bind
Navigation methods:
MethodDescription
field(key)Access nested object field
fieldArray(key)Access nested array field
Field actions:
MethodDescription
blur()Mark field as touched

FormFieldArray

Array field with additional methods for array manipulation:
Array operations:
MethodDescription
add(item?)Add new item to array. Returns a promise.
remove(index)Remove item by index. Returns a promise.
move(from, to)Move item to different position. Returns a promise.
Reactive array:
PropertyDescription
fieldsArray of field instances for iteration (FormField<T>[])
Individual array item access:
MethodDescription
field(path)Get individual array item fields (e.g., field(0), field('[0].name'))
fieldArray(path)Get nested array fields within array items

Working with fields

Field state

Each field provides reactive state that updates automatically:
<script setup>
const nameField = form.field('name')

// Reactive field state
console.log(nameField.value.value)        // Current value
console.log(nameField.errors.value)       // Array of error strings
console.log(nameField.errorMessage.value) // First error or undefined
console.log(nameField.isValid.value)      // true if no errors
console.log(nameField.isDirty.value)      // true if changed from initial
console.log(nameField.isTouched.value)    // true if user interacted
</script>

<template>
  <!-- Display field state -->
  <div class="field-debug">
    <p>Value: {{ nameField.value.value }}</p>
    <p>Valid: {{ nameField.isValid.value }}</p>
    <p>Dirty: {{ nameField.isDirty.value }}</p>
    <p>Touched: {{ nameField.isTouched.value }}</p>
    <p>Errors: {{ nameField.errors.value }}</p>
  </div>
</template>

Input binding

The inputAttrs property provides all necessary attributes for form inputs:
<template>
  <!-- Automatic binding with all attributes -->
  <input v-bind="nameField.inputAttrs.value" />

  <!-- Error message with proper ID linking -->
  <div
    v-if="nameField.errorMessage.value"
    :id="nameField.inputAttrs.value.id + '-error'"
    class="error"
  >
    {{ nameField.errorMessage.value }}
  </div>
</template>

Checkbox fields

LiveVue supports three checkbox patterns:
For simple true/false fields:
<script setup>
const acceptTerms = form.field('acceptTerms', { type: 'checkbox' })
</script>

<template>
  <label>
    <input v-bind="acceptTerms.inputAttrs.value" />
    I accept the terms and conditions
  </label>
</template>

Nested fields

Object navigation

Access nested object fields using dot notation:
type UserProfile = {
  name: string
  email: string
  address: {
    street: string
    city: string
    country: string
  }
  preferences: {
    newsletter: boolean
    theme: 'light' | 'dark'
  }
}

const form = useLiveForm<UserProfile>(/* ... */)

// Access nested fields with full type safety
const nameField = form.field('name')                           // FormField<string>
const streetField = form.field('address.street')               // FormField<string>
const themeField = form.field('preferences.theme')             // FormField<'light' | 'dark'>

Complex nested structures

<script setup lang="ts">
type CompanyForm = {
  name: string
  headquarters: {
    address: {
      street: string
      city: string
      postal_code: string
    }
    contact: {
      phone: string
      email: string
    }
  }
}

const form = useLiveForm<CompanyForm>(/* ... */)

const companyNameField = form.field('name')
const hqStreetField = form.field('headquarters.address.street')
const hqPhoneField = form.field('headquarters.contact.phone')
</script>

<template>
  <div>
    <input v-bind="companyNameField.inputAttrs.value" />

    <fieldset>
      <legend>Headquarters</legend>
      <input v-bind="hqStreetField.inputAttrs.value" />
      <input v-bind="form.field('headquarters.address.city').inputAttrs.value" />
      <input v-bind="hqPhoneField.inputAttrs.value" />
    </fieldset>
  </div>
</template>

Array fields

Basic array operations

<script setup lang="ts">
type TagsForm = {
  title: string
  tags: string[]
}

const form = useLiveForm<TagsForm>(/* ... */)
const tagsArray = form.fieldArray('tags')

const addTag = () => tagsArray.add('')
const removeTag = (index: number) => tagsArray.remove(index)
</script>

<template>
  <div>
    <fieldset>
      <legend>Tags</legend>
      <div v-for="(tagField, index) in tagsArray.fields.value" :key="index">
        <input v-bind="tagField.inputAttrs.value" placeholder="Enter tag" />
        <button @click="removeTag(index)">Remove</button>
      </div>
      <button @click="addTag()">Add Tag</button>
    </fieldset>
  </div>
</template>
If calling add() on an array field does not add a new item, it often means that your Ecto changeset is filtering out empty or invalid values. Make sure your changeset doesn’t consider the value you’re trying to add as empty, or provide a valid initial value when adding.

Complex array structures

Handle arrays of objects with nested properties:
<script setup lang="ts">
type ProjectForm = {
  name: string
  team_members: Array<{
    name: string
    email: string
    role: string
    skills: string[]
  }>
}

const form = useLiveForm<ProjectForm>(/* ... */)
const membersArray = form.fieldArray('team_members')

const addMember = () => {
  membersArray.add({
    name: '',
    email: '',
    role: 'developer',
    skills: []
  })
}
</script>

<template>
  <div>
    <fieldset>
      <legend>Team Members</legend>
      <div v-for="(memberField, memberIndex) in membersArray.fields.value" :key="memberIndex">
        <h4>Member {{ memberIndex + 1 }}</h4>
        
        <input v-bind="memberField.field('name').inputAttrs.value" />
        <input v-bind="memberField.field('email').inputAttrs.value" />
        
        <!-- Skills array (nested array) -->
        <fieldset>
          <legend>Skills</legend>
          <div
            v-for="(skillField, skillIndex) in memberField.fieldArray('skills').fields.value"
            :key="skillIndex"
          >
            <input v-bind="skillField.inputAttrs.value" />
            <button @click="memberField.fieldArray('skills').remove(skillIndex)">Remove</button>
          </div>
          <button @click="memberField.fieldArray('skills').add('')">Add Skill</button>
        </fieldset>

        <button @click="membersArray.remove(memberIndex)">Remove Member</button>
      </div>

      <button @click="addMember()">Add Team Member</button>
    </fieldset>
  </div>
</template>

Server-side integration

Ecto changeset integration

LiveVue forms work seamlessly with Ecto changesets:
defmodule MyApp.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :name, :string
    field :email, :string
    field :age, :integer

    embeds_one :profile, Profile do
      field :bio, :string
      field :skills, {:array, :string}, default: []
    end
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:name, :email, :age])
    |> validate_required([:name, :email])
    |> validate_format(:email, ~r/@/)
    |> validate_number(:age, greater_than: 0)
    |> cast_embed(:profile, with: &profile_changeset/2)
  end

  defp profile_changeset(profile, attrs) do
    profile
    |> cast(attrs, [:bio, :skills])
    |> validate_length(:bio, max: 500)
  end
end

LiveView form handling

defmodule MyAppWeb.UserFormLive do
  use MyAppWeb, :live_view

  def handle_event("validate", %{"user" => user_params}, socket) do
    user = socket.assigns.user || %User{}

    changeset =
      user
      |> User.changeset(user_params)
      # Setting :action is crucial - without it, the changeset won't expose errors
      |> Map.put(:action, :validate)

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

  def handle_event("submit", %{"user" => user_params}, socket) do
    case save_user(socket.assigns.user, user_params) do
      {:ok, user} ->
        socket =
          socket
          |> put_flash(:info, "User saved successfully!")
          |> redirect(to: ~p"/users/#{user}")

        {:noreply, socket}

      {:error, changeset} ->
        {:noreply, assign(socket, form: to_form(changeset, as: :user))}
    end
  end
end

Form reset on successful submission

By default, useLiveForm does not automatically reset form state after submission. You can opt into automatic form reset by returning {:reply, %{reset: true}, socket} from your submit event handler:
def handle_event("submit", %{"contact" => contact_params}, socket) do
  case create_contact(contact_params) do
    {:ok, contact} ->
      socket =
        socket
        |> put_flash(:info, "Contact created successfully!")
        |> assign(:form, to_form(Contact.changeset(%Contact{}, %{}), as: :contact))

      # Tell the Vue component to reset form state
      {:reply, %{reset: true}, socket}

    {:error, changeset} ->
      {:noreply, assign(socket, form: to_form(changeset, as: :contact))}
  end
end
What gets reset:
  • Reset all field values to their current server state
  • Clear all touched states (isTouched becomes false)
  • Reset submit count to 0
  • Clear dirty states (isDirty becomes false)
  • Update initialValues to match current form values

Advanced patterns

Data transformation

Transform form data before sending to the server using prepareData:
<script setup>
type RawForm = {
  name: string
  tags: string
  price: string
}

type ProcessedForm = {
  name: string
  tags: string[]
  price: number
}

const form = useLiveForm<RawForm>(() => props.form, {
  changeEvent: 'validate',
  submitEvent: 'submit',
  prepareData: (data: RawForm): ProcessedForm => {
    return {
      name: data.name.trim(),
      tags: data.tags.split(',').map(tag => tag.trim()).filter(Boolean),
      price: parseFloat(data.price) || 0
    }
  }
})
</script>

Conditional field logic

Show/hide fields based on form state:
<script setup>
type UserForm = {
  account_type: 'personal' | 'business'
  name: string
  company_name?: string
  tax_id?: string
}

const form = useLiveForm<UserForm>(/* ... */)
const accountTypeField = form.field('account_type')

const isBusinessAccount = computed(() =>
  accountTypeField.value.value === 'business'
)

// Clear conditional fields when they become hidden
watch(isBusinessAccount, (isBusiness) => {
  if (!isBusiness) {
    form.field('company_name').value.value = ''
    form.field('tax_id').value.value = ''
  }
})
</script>

<template>
  <div>
    <select v-bind="accountTypeField.inputAttrs.value">
      <option value="personal">Personal</option>
      <option value="business">Business</option>
    </select>

    <!-- Business-only fields -->
    <div v-if="isBusinessAccount">
      <input v-bind="form.field('company_name').inputAttrs.value" />
      <input v-bind="form.field('tax_id').inputAttrs.value" />
    </div>
  </div>
</template>

Next steps

Build docs developers (and LLMs) love