Forms and Validation

View Source

This guide covers the useLiveForm composable for building complex forms with server-side validation, nested objects, and dynamic arrays in LiveVue.

Getting Started

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:

Vue Component:

<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
})

// Access fields with full type safety
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 || form.isValidating.value">
      {{ form.isValidating.value ? 'Validating...' : 'Submit' }}
    </button>
    <button @click="form.reset()">Reset</button>
  </div>
</template>

LiveView Setup:

defmodule MyAppWeb.UserFormLive do
  use MyAppWeb, :live_view

  def render(assigns) do
    ~H"""
    <.vue form={@form} v-component="UserForm" v-socket={@socket} />
    """
  end

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

  def handle_event("validate", %{"user" => params}, socket) do
    changeset = User.changeset(%User{}, params)
    {:noreply, assign(socket, form: to_form(changeset, as: :user))}
  end

  def handle_event("submit", %{"user" => params}, socket) do
    changeset = User.changeset(%User{}, params)
    case Repo.insert(changeset) do
      {:ok, _user} -> {:noreply, redirect(socket, to: "/")}
      {:error, changeset} -> {:noreply, assign(socket, form: to_form(changeset, as: :user))}
    end
  end
end

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

Basic Usage

Setting Up a Form

First, set up your LiveView with form handling:

defmodule MyAppWeb.ContactFormLive do
  use MyAppWeb, :live_view

  def render(assigns) do
    ~H"""
    <.vue form={@form} v-component="ContactForm" v-socket={@socket} />
    """
  end

  def mount(_params, _session, socket) do
    changeset = Contact.changeset(%Contact{}, %{})
    {:ok, assign(socket, form: to_form(changeset, as: :contact))}
  end

  def handle_event("validate", %{"contact" => params}, socket) do
    changeset = Contact.changeset(%Contact{}, params)
    {:noreply, assign(socket, form: to_form(changeset, as: :contact))}
  end

  def handle_event("submit", %{"contact" => params}, socket) do
    changeset = Contact.changeset(%Contact{}, params)

    case Repo.insert(changeset) do
      {:ok, contact} ->
        {:noreply, put_flash(socket, :info, "Contact created successfully!")}
      {:error, changeset} ->
        {:noreply, assign(socket, form: to_form(changeset, as: :contact))}
    end
  end
end

Creating the Vue Component

Create a Vue component that uses useLiveForm:

<script setup lang="ts">
import { useLiveForm, type Form } from 'live_vue'

type ContactForm = {
  name: string
  email: string
  subject: string
  message: string
}

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

const form = useLiveForm(() => props.form, {
  changeEvent: 'validate',
  submitEvent: 'submit'
})

// Create typed field references
const nameField = form.field('name')
const emailField = form.field('email', { type: 'email' })
const subjectField = form.field('subject')
const messageField = form.field('message')
</script>

<template>
  <div class="contact-form">
    <div class="field">
      <label :for="nameField.inputAttrs.value.id">Name</label>
      <input
        v-bind="nameField.inputAttrs.value"
        :class="{ error: nameField.isTouched.value && nameField.errorMessage.value }"
      />
      <div v-if="nameField.errorMessage.value" class="error-message">
        {{ nameField.errorMessage.value }}
      </div>
    </div>

    <div class="field">
      <label :for="emailField.inputAttrs.value.id">Email</label>
      <input
        v-bind="emailField.inputAttrs.value"
        :class="{ error: emailField.isTouched.value && emailField.errorMessage.value }"
      />
      <div v-if="emailField.errorMessage.value" class="error-message">
        {{ emailField.errorMessage.value }}
      </div>
    </div>

    <div class="field">
      <label :for="subjectField.inputAttrs.value.id">Subject</label>
      <input v-bind="subjectField.inputAttrs.value" />
      <div v-if="subjectField.errorMessage.value" class="error-message">
        {{ subjectField.errorMessage.value }}
      </div>
    </div>

    <div class="field">
      <label :for="messageField.inputAttrs.value.id">Message</label>
      <textarea
        v-bind="messageField.inputAttrs.value"
        rows="5"
        :class="{ error: messageField.isTouched.value && messageField.errorMessage.value }"
      />
      <div v-if="messageField.errorMessage.value" class="error-message">
        {{ messageField.errorMessage.value }}
      </div>
    </div>

    <div class="form-actions">
      <button @click="form.reset()" type="button">Reset</button>
      <button
        @click="form.submit()"
        :disabled="!form.isValid.value"
        type="submit"
      >
        Submit
      </button>
    </div>

    <!-- Optional: Form state display -->
    <div class="form-status">
      <p>Valid: {{ form.isValid.value }}</p>
      <p>Dirty: {{ form.isDirty.value }}</p>
      <p>Touched: {{ form.isTouched.value }}</p>
      <p>Validating: {{ form.isValidating.value }}</p>
    </div>
  </div>
</template>

API Reference

useLiveForm(form, options)

Creates a reactive form instance with validation and state management.

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: UseLiveFormReturn<T>

Form Interface

The form data structure passed from LiveView:

PropertyTypeDescription
namestringForm identifier (e.g., "user", "contact")
valuesTCurrent form values
errorsobjectValidation errors from server (nested structure matching form shape)
validbooleanWhether the form is valid

UseLiveFormReturn Interface

The object returned by useLiveForm():

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 (debounced or executing)
submitCountReadonly<Ref<number>>Number of submission attempts. Resets to 0 after successful submission
initialValuesReadonly<Ref<T>>Original form values for reset

Field factory functions:

MethodDescription
field(path, options?)Get a typed field instance for the given path (e.g., "name", "user.email", "users[0].name") with optional field configuration
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 Interface

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 (onInput, onBlur), name, id, and accessibility attributes (aria-invalid, aria-describedby). Designed to be used 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 Interface

Array field with additional methods for array manipulation. If changeEvent is set, they will return a promise resolving when the item is validated through the server and added. Otherwise, promise resolves immediately.

Array operations:

MethodDescription
add(item?)Add new item to array (optionally with partial data). Returns a promise.
remove(index)Remove item by index. Returns a promise.
move(from, to)Move item to different position. Returns a promise.

Ecto by default removes empty values

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 (e.g., empty strings in an array). Make sure your changeset doesn't consider value you're trying to add as empty, or provide a valid initial value when adding.

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

Note: Array fields inherit all properties and methods from FormField except field() and fieldArray() navigation methods, which are replaced with array-specific versions.

Field Options

When creating fields, you can pass an optional options parameter to configure field behavior:

interface FieldOptions {
  type?: string     // HTML input type (e.g., "email", "password", "checkbox")
  value?: any       // For checkbox/radio: the value this field represents when selected
}

Examples:

// Basic field (infers type from data)
const nameField = form.field('name')

// Email input with validation
const emailField = form.field('email', { type: 'email' })

// Password input
const passwordField = form.field('password', { type: 'password' })

// Boolean checkbox
const acceptTerms = form.field('acceptTerms', { type: 'checkbox' })

// Single checkbox with custom value
const planField = form.field('plan', { type: 'checkbox', value: 'premium' })

// Multiple checkboxes (automatically detected when same field has different values)
const emailPrefs = form.field('preferences', { type: 'checkbox', value: 'email' })
const smsPrefs = form.field('preferences', { type: 'checkbox', value: 'sms' })

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" />

  <!-- Manual binding (equivalent to above) -->
  <input
    :value="nameField.inputAttrs.value.value"
    @input="nameField.inputAttrs.value.onInput"
    @blur="nameField.inputAttrs.value.onBlur"
    :name="nameField.inputAttrs.value.name"
    :id="nameField.inputAttrs.value.id"
    :aria-invalid="nameField.inputAttrs.value['aria-invalid']"
    :aria-describedby="nameField.inputAttrs.value['aria-describedby']"
  />

  <!-- 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>

Custom Field Components

Create reusable field components by extracting the binding logic:

<!-- TextInput.vue -->
<script setup lang="ts">
interface Props {
  field: FormField<string>
  label: string
  type?: string
  placeholder?: string
}

const props = withDefaults(defineProps<Props>(), {
  type: 'text'
})
</script>

<template>
  <div class="field">
    <label :for="field.inputAttrs.value.id">{{ label }}</label>
    <input
      v-bind="field.inputAttrs.value"
      :type="type"
      :placeholder="placeholder"
      :class="{ error: field.isTouched.value && field.errorMessage.value }"
    />
    <div v-if="field.errorMessage.value" class="error-message">
      {{ field.errorMessage.value }}
    </div>
  </div>
</template>

Usage:

<TextInput :field="form.field('name')" label="Full Name" placeholder="Enter your name" />
<TextInput :field="form.field('email', { type: 'email' })" label="Email" />

Checkbox Fields

LiveVue supports three checkbox patterns that automatically handle different use cases:

1. Boolean Checkbox

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>

2. Single Checkbox with Custom Value

For fields that should have a specific value when checked:

<script setup>
const plan = form.field('plan', { type: 'checkbox', value: 'premium' })
</script>

<template>
  <label>
    <input v-bind="plan.inputAttrs.value" />
    Upgrade to Premium ($9.99/month)
  </label>
  <!-- When checked: plan.value = 'premium', when unchecked: plan.value = null -->
</template>

3. Multiple Checkboxes (Array)

When you create multiple checkboxes for the same field path with different values, LiveVue automatically detects this as a multi-checkbox scenario and manages an array:

<script setup>
// These automatically become array-aware
const emailPref = form.field('preferences', { type: 'checkbox', value: 'email' })
const smsPref = form.field('preferences', { type: 'checkbox', value: 'sms' })
const pushPref = form.field('preferences', { type: 'checkbox', value: 'push' })
</script>

<template>
  <fieldset>
    <legend>Notification Preferences</legend>
    
    <label>
      <input v-bind="emailPref.inputAttrs.value" />
      Email notifications
    </label>
    
    <label>
      <input v-bind="smsPref.inputAttrs.value" />
      SMS notifications  
    </label>
    
    <label>
      <input v-bind="pushPref.inputAttrs.value" />
      Push notifications
    </label>
  </fieldset>
  <!-- When checked, values are added/removed from the array automatically -->
  <!-- e.g., preferences.value = ['email', 'push'] -->
</template>

The checkbox behavior is automatically determined by:

  • Boolean: type: 'checkbox' without value option
  • Single with value: type: 'checkbox' with value option
  • Multiple: Same field path used with different value options

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'
    notifications: {
      email: boolean
      push: boolean
    }
  }
}

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'>
const emailNotifField = form.field('preferences.notifications.email') // FormField<boolean>

Fluent Field Navigation

You can also navigate through object structures using the field methods:

// Equivalent ways to access nested fields
const emailNotifField1 = form.field('preferences.notifications.email')

const preferencesField = form.field('preferences')
const notificationsField = preferencesField.field('notifications')
const emailNotifField2 = notificationsField.field('email')

// Both approaches are type-safe and equivalent

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
    }
  }
  departments: Array<{
    name: string
    manager: {
      name: string
      email: string
    }
  }>
}

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

// Access deeply nested fields
const companyNameField = form.field('name')
const hqStreetField = form.field('headquarters.address.street')
const hqPhoneField = form.field('headquarters.contact.phone')
const departmentsArray = form.fieldArray('departments')
</script>

<template>
  <div>
    <!-- Company basic info -->
    <TextInput :field="companyNameField" label="Company Name" />

    <!-- Headquarters address -->
    <fieldset>
      <legend>Headquarters</legend>
      <TextInput :field="hqStreetField" label="Street" />
      <TextInput :field="form.field('headquarters.address.city')" label="City" />
      <TextInput :field="form.field('headquarters.address.postal_code')" label="Postal Code" />
      <TextInput :field="hqPhoneField" label="Phone" />
      <TextInput :field="form.field('headquarters.contact.email')" label="Email" type="email" />
    </fieldset>

    <!-- Departments array -->
    <fieldset>
      <legend>Departments</legend>
      <div v-for="(deptField, index) in departmentsArray.fields.value" :key="index">
        <h4>Department {{ index + 1 }}</h4>
        <TextInput :field="deptField.field('name')" label="Department Name" />
        <TextInput :field="deptField.field('manager.name')" label="Manager Name" />
        <TextInput :field="deptField.field('manager.email')" label="Manager Email" type="email" />
        <button @click="departmentsArray.remove(index)">Remove Department</button>
      </div>
      <button @click="departmentsArray.add({ name: '', manager: { name: '', email: '' } })">
        Add Department
      </button>
    </fieldset>
  </div>
</template>

Array Fields

Basic Array Operations

Array fields provide methods for adding, removing, and reordering items:

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

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

// Array operations
const addTag = () => tagsArray.add('')
const removeTag = (index: number) => tagsArray.remove(index)
const moveTag = (from: number, to: number) => tagsArray.move(from, to)
</script>

<template>
  <div>
    <TextInput :field="titleField" label="Title" />

    <!-- Tags array -->
    <fieldset>
      <legend>Tags</legend>
      <div v-for="(tagField, index) in tagsArray.fields.value" :key="index" class="tag-item">
        <input v-bind="tagField.inputAttrs.value" placeholder="Enter tag" />
        <button @click="removeTag(index)">Remove</button>
        <button v-if="index > 0" @click="moveTag(index, index - 1)"></button>
        <button v-if="index < tagsArray.fields.value.length - 1" @click="moveTag(index, index + 1)"></button>
      </div>
      <button @click="addTag()">Add Tag</button>
    </fieldset>
  </div>
</template>

Array Field Indexing

Access individual array items using multiple syntaxes:

const skillsArray = form.fieldArray('profile.skills')

// Method 1: Using numeric index
const firstSkill = skillsArray.field(0)                    // FormField<string>

// Method 2: Using bracket notation
const secondSkill = skillsArray.field('[1]')               // FormField<string>

// Method 3: Using the fields array (for iteration)
const allSkillFields = skillsArray.fields.value            // FormField<string>[]

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[]
    contact: {
      phone: string
      address: string
    }
  }>
}

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

const addMember = () => {
  membersArray.add({
    name: '',
    email: '',
    role: 'developer',
    skills: [],
    contact: { phone: '', address: '' }
  })
}

const addSkillToMember = (memberIndex: number) => {
  const memberField = membersArray.field(memberIndex)
  const skillsArray = memberField.fieldArray('skills')
  skillsArray.add('')
}
</script>

<template>
  <div>
    <TextInput :field="form.field('name')" label="Project Name" />

    <fieldset>
      <legend>Team Members</legend>
      <div v-for="(memberField, memberIndex) in membersArray.fields.value" :key="memberIndex" class="member-card">
        <h4>Member {{ memberIndex + 1 }}</h4>

        <!-- Basic member info -->
        <TextInput :field="memberField.field('name')" label="Name" />
        <TextInput :field="memberField.field('email')" label="Email" type="email" />
        <select v-bind="memberField.field('role').inputAttrs.value">
          <option value="developer">Developer</option>
          <option value="designer">Designer</option>
          <option value="manager">Manager</option>
        </select>

        <!-- Contact info (nested object) -->
        <fieldset>
          <legend>Contact Information</legend>
          <TextInput :field="memberField.field('contact.phone')" label="Phone" />
          <TextInput :field="memberField.field('contact.address')" label="Address" />
        </fieldset>

        <!-- Skills array (nested array) -->
        <fieldset>
          <legend>Skills</legend>
          <div
            v-for="(skillField, skillIndex) in memberField.fieldArray('skills').fields.value"
            :key="skillIndex"
            class="skill-item"
          >
            <input v-bind="skillField.inputAttrs.value" placeholder="Enter skill" />
            <button @click="memberField.fieldArray('skills').remove(skillIndex)">Remove</button>
          </div>
          <button @click="addSkillToMember(memberIndex)">Add Skill</button>
        </fieldset>

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

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

Deeply Nested Arrays

Handle arrays within arrays within objects:

type BlogPost = {
  title: string
  sections: Array<{
    heading: string
    paragraphs: string[]
    comments: Array<{
      author: string
      text: string
      replies: Array<{
        author: string
        text: string
      }>
    }>
  }>
}

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

// Access deeply nested arrays
const sectionsArray = form.fieldArray('sections')
const firstSectionParagraphs = sectionsArray.field('[0].paragraphs')  // FormFieldArray<string>
const firstSectionComments = sectionsArray.field('[0].comments')      // FormFieldArray<Comment>

// Using fluent interface
const firstSection = sectionsArray.field(0)                           // FormField<Section>
const firstSectionParagraphsAlt = firstSection.fieldArray('paragraphs') // FormFieldArray<string>

// Access replies of first comment in first section
const firstCommentReplies = sectionsArray.fieldArray('[0].comments').field('[0].replies') // FormFieldArray<Reply>

Form State Management

Form-Level State

The form instance provides reactive state about the entire form:

<script setup>
const form = useLiveForm(/* ... */)

// Form state is reactive
watch(() => form.isValid.value, (valid) => {
  console.log('Form validity changed:', valid)
})

watch(() => form.isDirty.value, (dirty) => {
  if (dirty) {
    console.log('Form has unsaved changes')
  }
})

watch(() => form.isValidating.value, (validating) => {
  console.log('Validation status:', validating ? 'in progress' : 'complete')
})
</script>

<template>
  <div class="form-status">
    <!-- Visual indicators -->
    <div :class="{ 'status-valid': form.isValid.value, 'status-invalid': !form.isValid.value }">
      {{ form.isValid.value ? '✓ Valid' : '✗ Has Errors' }}
    </div>

    <div :class="{ 'status-dirty': form.isDirty.value }">
      {{ form.isDirty.value ? '● Unsaved Changes' : '✓ Saved' }}
    </div>

    <div v-if="form.isTouched.value">
      User has interacted with the form
    </div>

    <div v-if="form.isValidating.value" class="validating">
      🔄 Validating changes...
    </div>

    <div>
      Submit attempts: {{ form.submitCount.value }}
    </div>
  </div>

  <!-- Conditional submit button -->
  <button
    @click="form.submit()"
    :disabled="!form.isValid.value || !form.isDirty.value || form.isValidating.value"
    class="submit-btn"
  >
    {{ form.isValidating.value ? 'Validating...' : form.isDirty.value ? 'Save Changes' : 'No Changes' }}
  </button>
</template>

Reset and Submit Actions

<script setup>
const form = useLiveForm(/* ... */)

const handleSubmit = async () => {
  try {
    await form.submit()
    console.log('Form submitted successfully!')
    // Form will automatically reset on successful submission
  } catch (error) {
    console.error('Submission failed:', error)
    // Form state remains intact for user to fix errors
  }
}

const handleReset = () => {
  if (form.isDirty.value) {
    if (confirm('You have unsaved changes. Are you sure you want to reset?')) {
      form.reset()
    }
  } else {
    form.reset()
  }
}

const handleCancel = () => {
  // Navigate away or close modal
  if (form.isDirty.value) {
    if (confirm('You have unsaved changes. Are you sure you want to cancel?')) {
      // Navigate away
      $live.pushEvent('cancel')
    }
  }
}
</script>

<template>
  <div class="form-actions">
    <button @click="handleReset" type="button" :disabled="!form.isDirty.value">
      Reset
    </button>
    <button @click="handleCancel" type="button">
      Cancel
    </button>
    <button @click="handleSubmit" :disabled="!form.isValid.value">
      {{ form.submitCount.value > 0 ? 'Resubmit' : 'Submit' }}
    </button>
  </div>
</template>

Advanced Patterns

Custom Validation Logic

While server-side validation is primary, you can add client-side validation for better UX:

<script setup>
const form = useLiveForm(/* ... */)
const emailField = form.field('email')
const passwordField = form.field('password')
const confirmPasswordField = form.field('confirm_password')

// Client-side validation helpers
const emailIsValid = computed(() => {
  const value = emailField.value.value
  return !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
})

const passwordsMatch = computed(() => {
  return passwordField.value.value === confirmPasswordField.value.value
})

// Combine client and server validation
const emailHasError = computed(() => {
  return (emailField.isTouched.value && !emailIsValid.value) ||
         (emailField.errorMessage.value !== undefined)
})
</script>

<template>
  <div class="field">
    <input
      v-bind="emailField.inputAttrs.value"
      type="email"
      :class="{ error: emailHasError.value }"
    />
    <!-- Show client-side error first, then server error -->
    <div v-if="emailField.isTouched.value && !emailIsValid.value" class="error">
      Please enter a valid email address
    </div>
    <div v-else-if="emailField.errorMessage.value" class="error">
      {{ emailField.errorMessage.value }}
    </div>
  </div>

  <div class="field">
    <input v-bind="confirmPasswordField.inputAttrs.value" type="password" />
    <div v-if="confirmPasswordField.isTouched.value && !passwordsMatch.value" class="error">
      Passwords do not match
    </div>
  </div>
</template>

Data Transformation

Transform form data before sending to the server. The prepareData function is applied to form data before both changeEvent (validation) and submitEvent (submission) requests, ensuring consistent data transformation for all server communication:

<script setup>
type RawForm = {
  name: string
  tags: string
  price: string
  is_active: string
}

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

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,
      is_active: data.is_active === 'true'
    }
  }
})

// Form fields work with the raw format
const tagsField = form.field('tags')  // string field for comma-separated input
const priceField = form.field('price')  // string field for user input
</script>

<template>
  <!-- User enters comma-separated tags -->
  <input v-bind="tagsField.inputAttrs.value" placeholder="javascript, vue, forms" />

  <!-- User enters price as string -->
  <input v-bind="priceField.inputAttrs.value" type="number" step="0.01" />
</template>

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
  billing_address: string
  shipping_address?: string
  same_as_billing: boolean
}

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

const accountTypeField = form.field('account_type')
const sameAsBillingField = form.field('same_as_billing')

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

const needsShippingAddress = computed(() =>
  !sameAsBillingField.value.value
)

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

watch(needsShippingAddress, (needsShipping) => {
  if (!needsShipping) {
    form.field('shipping_address').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">
      <TextInput :field="form.field('company_name')" label="Company Name" />
      <TextInput :field="form.field('tax_id')" label="Tax ID" />
    </div>

    <TextInput :field="form.field('billing_address')" label="Billing Address" />

    <label>
      <input type="checkbox" v-bind="sameAsBillingField.inputAttrs.value" />
      Shipping address same as billing
    </label>

    <div v-if="needsShippingAddress">
      <TextInput :field="form.field('shipping_address')" label="Shipping Address" />
    </div>
  </div>
</template>

Provide/Inject API (Advanced)

For building reusable form components, LiveVue provides useField() and useArrayField() hooks that work with Vue's provide/inject system.

Advanced API

This is an advanced pattern for creating reusable form components. Most applications should use the direct field access patterns shown above.

Component Injection

When you call useLiveForm(), it automatically provides the form instance to child components:

<!-- ParentForm.vue -->
<script setup>
const form = useLiveForm(/* ... */)
// Form instance is automatically provided to children
</script>

<template>
  <div>
    <!-- These child components can access form fields -->
    <UserNameInput />
    <UserEmailInput />
    <SkillsManager />
  </div>
</template>

useField Hook

Create reusable field components that access the parent form:

<!-- UserNameInput.vue -->
<script setup lang="ts">
import { useField } from 'live_vue'

interface Props {
  path?: string
}

const props = withDefaults(defineProps<Props>(), {
  path: 'name'
})

// Access field from parent form context
const field = useField<string>(props.path)
</script>

<template>
  <div class="field">
    <label :for="field.inputAttrs.value.id">Full Name</label>
    <input
      v-bind="field.inputAttrs.value"
      :class="{ error: field.isTouched.value && field.errorMessage.value }"
      placeholder="Enter your full name"
    />
    <div v-if="field.errorMessage.value" class="error-message">
      {{ field.errorMessage.value }}
    </div>
  </div>
</template>

useArrayField Hook

Create reusable array field managers:

<!-- SkillsManager.vue -->
<script setup lang="ts">
import { useArrayField } from 'live_vue'

interface Props {
  path?: string
}

const props = withDefaults(defineProps<Props>(), {
  path: 'skills'
})

const skillsArray = useArrayField<string>(props.path)

const addSkill = () => skillsArray.add('')
const removeSkill = (index: number) => skillsArray.remove(index)
</script>

<template>
  <fieldset>
    <legend>Skills</legend>

    <div v-for="(skillField, index) in skillsArray.fields.value" :key="index" class="skill-item">
      <input v-bind="skillField.inputAttrs.value" placeholder="Enter skill" />
      <button @click="removeSkill(index)" type="button">Remove</button>
    </div>

    <button @click="addSkill" type="button">Add Skill</button>

    <div v-if="skillsArray.errorMessage.value" class="error-message">
      {{ skillsArray.errorMessage.value }}
    </div>
  </fieldset>
</template>

Complex Reusable Components

Build sophisticated reusable form sections:

<!-- AddressInput.vue -->
<script setup lang="ts">
import { useField } from 'live_vue'

interface Props {
  basePath: string      // e.g., 'billing_address' or 'shipping_address'
  label: string
}

const props = defineProps<Props>()

// Access nested address fields
const streetField = useField<string>(`${props.basePath}.street`)
const cityField = useField<string>(`${props.basePath}.city`)
const stateField = useField<string>(`${props.basePath}.state`)
const zipField = useField<string>(`${props.basePath}.zip`)
const countryField = useField<string>(`${props.basePath}.country`)
</script>

<template>
  <fieldset>
    <legend>{{ label }}</legend>

    <div class="address-grid">
      <TextInput :field="streetField" label="Street Address" />
      <TextInput :field="cityField" label="City" />
      <TextInput :field="stateField" label="State" />
      <TextInput :field="zipField" label="ZIP Code" />

      <div class="field">
        <label :for="countryField.inputAttrs.value.id">Country</label>
        <select v-bind="countryField.inputAttrs.value">
          <option value="US">United States</option>
          <option value="CA">Canada</option>
          <option value="UK">United Kingdom</option>
        </select>
      </div>
    </div>
  </fieldset>
</template>

Usage:

<template>
  <div>
    <AddressInput base-path="billing_address" label="Billing Address" />
    <AddressInput base-path="shipping_address" label="Shipping Address" />
  </div>
</template>

Error Handling with Inject

<!-- Component using injection -->
<script setup>
import { useField, useArrayField } from 'live_vue'

// These composables require a parent component that called useLiveForm()
const nameField = useField('name')
const skillsArray = useArrayField('skills')
</script>

If useField() or useArrayField() are used outside a form context (i.e., not in a child component of one that called useLiveForm()), they will throw an error:

useField() can only be used inside components where a form has been provided.
Make sure a parent component calls useLiveForm() first.

This error indicates you need to either:

  1. Call useLiveForm() in a parent component, or
  2. Use the form.field() method directly instead of useField()

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

    has_many :posts, Post
  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 render(assigns) do
    ~H"""
    <div class="user-form">
      <h1>User Profile</h1>
      <.vue form={@form} v-component="UserForm" v-socket={@socket} />
    </div>
    """
  end

  def mount(%{"id" => id}, _session, socket) do
    user = Users.get_user!(id)
    changeset = User.changeset(user, %{})

    socket =
      socket
      |> assign(:user, user)
      |> assign(:form, to_form(changeset, as: :user))

    {:ok, socket}
  end

  def mount(_params, _session, socket) do
    # New user
    changeset = User.changeset(%User{}, %{})

    socket =
      socket
      |> assign(:user, nil)
      |> assign(:form, to_form(changeset, as: :user))

    {:ok, socket}
  end

  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.
      # Ecto changesets only show errors when an action is set (like :validate, :insert, :update).
      # This triggers error display in the Vue form without actually persisting data.
      |> 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

  defp save_user(nil, user_params) do
    %User{}
    |> User.changeset(user_params)
    |> Repo.insert()
  end

  defp save_user(user, user_params) do
    user
    |> User.changeset(user_params)
    |> Repo.update()
  end
end

Validation Best Practices

# In your context module
defmodule MyApp.Users do
  def change_user(user \\ %User{}, attrs \\ %{}) do
    User.changeset(user, attrs)
  end

  def create_user(attrs \\ %{}) do
    %User{}
    |> User.changeset(attrs)
    |> Repo.insert()
  end

  def update_user(user, attrs) do
    user
    |> User.changeset(attrs)
    |> Repo.update()
  end

  def validate_user_step(user, attrs, step) do
    case step do
      :basic_info ->
        user
        |> cast(attrs, [:name, :email])
        |> validate_required([:name, :email])

      :profile ->
        user
        |> cast_embed(:profile)

      :complete ->
        User.changeset(user, attrs)
    end
  end
end

Form Reset on Successful Submission

By default, useLiveForm does not automatically reset form state after submission. This allows for flexible handling of different success scenarios (redirects, modals, etc.). However, you can opt into automatic form reset by returning {:reply, %{reset: true}, socket} from your submit event handler.

Manual Form Management (Default Behavior)

def handle_event("submit", %{"user" => user_params}, socket) do
  case save_user(socket.assigns.user, user_params) do
    {:ok, user} ->
      # Redirect after successful save
      {:noreply, redirect(socket, to: ~p"/users/#{user}")}

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

Automatic Form Reset

When you want the form to reset after successful submission (useful for "create another" workflows or staying on the same page), use the {:reply, %{reset: true}, socket} pattern:

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

When {reset: true} is received, the Vue form component will:

  • Reset all field values to their current server state (from form.values)
  • 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

Multi-Step Form Reset

For multi-step forms or complex workflows, you can conditionally reset:

def handle_event("submit", %{"order" => order_params}, socket) do
  case create_order(order_params) do
    {:ok, order} ->
      socket = put_flash(socket, :info, "Order created successfully!")

      case socket.assigns.action do
        :create_another ->
          # Reset form to create another order
          socket = assign(socket, form: to_form(Order.changeset(%Order{}, %{}), as: :order))
          {:reply, %{reset: true}, socket}

        :checkout ->
          # Redirect to checkout
          {:noreply, redirect(socket, to: ~p"/checkout/#{order}")}

        :view_order ->
          # Redirect to order details
          {:noreply, redirect(socket, to: ~p"/orders/#{order}")}
      end

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

Vue Component Handling

The Vue component automatically handles the reset response:

<script setup>
const form = useLiveForm(() => props.form, {
  submitEvent: 'submit'
})

const handleSubmit = async () => {
  try {
    const result = await form.submit()
    
    // If server returned {reset: true}, form is already reset
    if (result?.reset) {
      console.log('Form was reset after successful submission')
    }
    
    // Additional success handling if needed
  } catch (error) {
    console.error('Submission failed:', error)
  }
}
</script>

Best Practices

  1. Use reset for "create another" workflows where users typically want to submit multiple similar forms
  2. Don't use reset when redirecting - if you're redirecting after success, reset is unnecessary
  3. Reset with fresh form data - always assign a new form with fresh initial values before returning {:reply, %{reset: true}, socket}
  4. Consider user experience - reset only when it improves the user flow, not as a default behavior
# Good: Reset for contact form that stays on same page
def handle_event("submit", %{"contact" => params}, socket) do
  case MyApp.Contacts.create_contact(params) do
    {:ok, _contact} ->
      socket =
        socket
        |> put_flash(:info, "Message sent successfully!")
        |> assign(:form, to_form(Contact.changeset(%Contact{}, %{}), as: :contact))
      
      {:reply, %{reset: true}, socket}
      
    {:error, changeset} ->
      {:noreply, assign(socket, form: to_form(changeset, as: :contact))}
  end
end

# Also good: No reset when redirecting
def handle_event("submit", %{"user" => params}, socket) do
  case MyApp.Accounts.create_user(params) do
    {:ok, user} ->
      {:noreply, redirect(socket, to: ~p"/users/#{user}")}
      
    {:error, changeset} ->
      {:noreply, assign(socket, form: to_form(changeset, as: :user))}
  end
end

Complete Examples

Simple Contact Form

A basic form demonstrating core concepts:

<!-- ContactForm.vue -->
<script setup lang="ts">
import { useLiveForm, type Form } from 'live_vue'

type ContactForm = {
  name: string
  email: string
  subject: string
  message: string
  contact_method: 'email' | 'phone'
  phone?: string
}

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

const form = useLiveForm(() => props.form, {
  changeEvent: 'validate',
  submitEvent: 'submit',
  debounceInMiliseconds: 200
})

const nameField = form.field('name')
const emailField = form.field('email', { type: 'email' })
const subjectField = form.field('subject')
const messageField = form.field('message')
// For radio buttons, create separate field instances with different values
// The { type: 'radio', value: '...' } options ensure proper checked state binding
const contactMethodEmail = form.field('contact_method', { type: 'radio', value: 'email' })
const contactMethodPhone = form.field('contact_method', { type: 'radio', value: 'phone' })
const phoneField = form.field('phone', { type: 'tel' })

const needsPhone = computed(() =>
  contactMethodEmail.value.value === 'phone'
)

const submitForm = async () => {
  try {
    await form.submit()
    // Success feedback will be handled by LiveView
  } catch (error) {
    console.error('Submission failed:', error)
  }
}
</script>

<template>
  <div class="contact-form">
    <h2>Contact Us</h2>

    <div class="form-grid">
      <div class="field">
        <label :for="nameField.inputAttrs.value.id">Name *</label>
        <input
          v-bind="nameField.inputAttrs.value"
          :class="{ error: nameField.isTouched.value && nameField.errorMessage.value }"
        />
        <div v-if="nameField.errorMessage.value" class="error-message">
          {{ nameField.errorMessage.value }}
        </div>
      </div>

      <div class="field">
        <label :for="emailField.inputAttrs.value.id">Email *</label>
        <input
          v-bind="emailField.inputAttrs.value"
          :class="{ error: emailField.isTouched.value && emailField.errorMessage.value }"
        />
        <div v-if="emailField.errorMessage.value" class="error-message">
          {{ emailField.errorMessage.value }}
        </div>
      </div>
    </div>

    <div class="field">
      <label :for="subjectField.inputAttrs.value.id">Subject *</label>
      <input v-bind="subjectField.inputAttrs.value" />
      <div v-if="subjectField.errorMessage.value" class="error-message">
        {{ subjectField.errorMessage.value }}
      </div>
    </div>

    <div class="field">
      <label>Preferred Contact Method</label>
      <div class="radio-group">
        <label>
          <!-- inputAttrs includes type="radio", value="email", checked state, and event handlers -->
          <input v-bind="contactMethodEmail.inputAttrs.value" />
          Email
        </label>
        <label>
          <input v-bind="contactMethodPhone.inputAttrs.value" />
          Phone
        </label>
      </div>
    </div>

    <div v-if="needsPhone" class="field">
      <label :for="phoneField.inputAttrs.value.id">Phone Number</label>
      <input v-bind="phoneField.inputAttrs.value" />
      <div v-if="phoneField.errorMessage.value" class="error-message">
        {{ phoneField.errorMessage.value }}
      </div>
    </div>

    <div class="field">
      <label :for="messageField.inputAttrs.value.id">Message *</label>
      <textarea
        v-bind="messageField.inputAttrs.value"
        rows="5"
        :class="{ error: messageField.isTouched.value && messageField.errorMessage.value }"
      />
      <div v-if="messageField.errorMessage.value" class="error-message">
        {{ messageField.errorMessage.value }}
      </div>
    </div>

    <div class="form-actions">
      <button @click="form.reset()" type="button" :disabled="!form.isDirty.value">
        Reset
      </button>
      <button @click="submitForm" :disabled="!form.isValid.value || form.isValidating.value" class="primary">
        {{ form.isValidating.value ? 'Validating...' : 'Send Message' }}
      </button>
    </div>

    <div class="form-status">
      <small>
        <span :class="{ valid: form.isValid.value, invalid: !form.isValid.value }">
          {{ form.isValid.value ? '✓' : '✗' }}
        </span>
        {{ form.isValidating.value ? 'Validating...' : form.isDirty.value ? 'Unsaved changes' : 'Form ready' }}
      </small>
    </div>
  </div>
</template>

<style scoped>
.contact-form {
  max-width: 600px;
  margin: 0 auto;
  padding: 2rem;
}

.form-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1rem;
}

.field {
  margin-bottom: 1rem;
}

.field label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: 500;
}

.field input, .field textarea, .field select {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.field input.error, .field textarea.error {
  border-color: #e74c3c;
}

.radio-group {
  display: flex;
  gap: 1rem;
}

.radio-group label {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-weight: normal;
}

.error-message {
  color: #e74c3c;
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

.form-actions {
  display: flex;
  gap: 1rem;
  margin-top: 2rem;
}

.form-actions button {
  padding: 0.75rem 1.5rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.form-actions button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.form-actions button.primary {
  background: #3498db;
  color: white;
}

.form-status {
  margin-top: 1rem;
  text-align: center;
}

.valid { color: #27ae60; }
.invalid { color: #e74c3c; }
</style>

Complex Nested Form

An advanced form with nested objects, arrays, and dynamic fields can include:

  • Nested object fields (owner.name, owner.email)
  • Array fields with objects (team_members[])
  • Deeply nested arrays (tasks[].assignees[])
  • Dynamic field operations (add/remove/reorder)
  • Complex validation scenarios
  • Form state management

See the patterns earlier in this guide for implementing each of these features, or explore interactive examples at livevue.skalecki.dev.

Next Steps

Now that you understand LiveVue forms, you might want to explore: