Forms and Validation
View SourceThis 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
endWhy 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
endCreating 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:
| Option | Type | Default | Description |
|---|---|---|---|
changeEvent | string | null | null | Event sent on field changes (set to null to disable validation events) |
submitEvent | string | "submit" | Event sent on form submission |
debounceInMiliseconds | number | 300 | Debounce delay for change events to reduce server load |
prepareData | function | (data) => data | Transform data before sending to server |
Returns: UseLiveFormReturn<T>
Form Interface
The form data structure passed from LiveView:
| Property | Type | Description |
|---|---|---|
name | string | Form identifier (e.g., "user", "contact") |
values | T | Current form values |
errors | object | Validation errors from server (nested structure matching form shape) |
valid | boolean | Whether the form is valid |
UseLiveFormReturn Interface
The object returned by useLiveForm():
Form-level state:
| Property | Type | Description |
|---|---|---|
isValid | Ref<boolean> | No validation errors exist |
isDirty | Ref<boolean> | Form values differ from initial |
isTouched | Ref<boolean> | At least one field has been interacted with |
isValidating | Readonly<Ref<boolean>> | Whether validation requests are in progress (debounced or executing) |
submitCount | Readonly<Ref<number>> | Number of submission attempts. Resets to 0 after successful submission |
initialValues | Readonly<Ref<T>> | Original form values for reset |
Field factory functions:
| Method | Description |
|---|---|
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:
| Method | Description |
|---|---|
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:
| Property | Type | Description |
|---|---|---|
value | Ref<T> | Current field value |
errors | Readonly<Ref<string[]>> | Validation errors from server |
errorMessage | Readonly<Ref<string | undefined>> | First error message |
isValid | Ref<boolean> | No validation errors |
isDirty | Ref<boolean> | Value differs from initial |
isTouched | Ref<boolean> | Field has been blurred |
Input binding:
| Property | Description |
|---|---|
inputAttrs | Object containing value, event handlers (onInput, onBlur), name, id, and accessibility attributes (aria-invalid, aria-describedby). Designed to be used with v-bind |
Navigation methods:
| Method | Description |
|---|---|
field(key) | Access nested object field |
fieldArray(key) | Access nested array field |
Field actions:
| Method | Description |
|---|---|
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:
| Method | Description |
|---|---|
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:
| Property | Description |
|---|---|
fields | Array of field instances for iteration (FormField<T>[]) |
Individual array item access:
| Method | Description |
|---|---|
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
FormFieldexceptfield()andfieldArray()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'withoutvalueoption - Single with value:
type: 'checkbox'withvalueoption - Multiple: Same field path used with different
valueoptions
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 equivalentComplex 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:
- Call
useLiveForm()in a parent component, or - Use the
form.field()method directly instead ofuseField()
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
endLiveView 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
endValidation 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
endForm 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
endAutomatic 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
endWhat 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 (
isTouchedbecomesfalse) - Reset submit count to 0
- Clear dirty states (
isDirtybecomesfalse) - Update
initialValuesto 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
endVue 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
- Use reset for "create another" workflows where users typically want to submit multiple similar forms
- Don't use reset when redirecting - if you're redirecting after success, reset is unnecessary
- Reset with fresh form data - always assign a new form with fresh initial values before returning
{:reply, %{reset: true}, socket} - 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
endComplete 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:
- Client-Side API for detailed API reference and advanced patterns
- Testing for testing form components and validation logic
- Component Reference for LiveView-side form integration
- Basic Usage for other LiveVue patterns and features