LiveVue
1.0
Simple Form
Build forms with useLiveForm()
that sync with Ecto changesets. Server-side validation with real-time feedback,
all powered by Phoenix LiveView.
What this example shows
<script setup lang="ts">
import { computed } from "vue"
import { useLiveForm, type Form } from "live_vue"
type ContactForm = {
name: string
email: string
consent: boolean
}
const props = defineProps<{ form: Form<ContactForm>; submitted: ContactForm | null }>()
const form = useLiveForm(() => props.form, {
changeEvent: "validate",
submitEvent: "submit",
debounceInMiliseconds: 300,
})
const nameField = form.field("name")
const emailField = form.field("email")
const consentField = form.field("consent", { type: "checkbox" })
const fields = [nameField, emailField, consentField]
const hasVisibleErrors = computed(() => fields.some((f) => f.isTouched.value && f.errorMessage.value))
</script>
<template>
<div class="card bg-base-200 p-6 space-y-6">
<div class="flex flex-col gap-6">
<label class="form-control w-full">
<div class="label pb-2"><span class="label-text font-medium">Name</span></div>
<input
v-bind="nameField.inputAttrs.value"
type="text"
placeholder="Your name"
:class="[
'input input-bordered w-full',
nameField.isTouched.value && nameField.errorMessage.value && 'input-error',
]"
/>
<div v-if="nameField.isTouched.value && nameField.errorMessage.value" class="label">
<span class="label-text-alt text-error">{{ nameField.errorMessage.value }}</span>
</div>
</label>
<label class="form-control w-full">
<div class="label pb-2"><span class="label-text font-medium">Email</span></div>
<input
v-bind="emailField.inputAttrs.value"
type="email"
placeholder="you@example.com"
:class="[
'input input-bordered w-full',
emailField.isTouched.value && emailField.errorMessage.value && 'input-error',
]"
/>
<div v-if="emailField.isTouched.value && emailField.errorMessage.value" class="label">
<span class="label-text-alt text-error">{{ emailField.errorMessage.value }}</span>
</div>
</label>
<div class="form-control pt-2">
<label class="label cursor-pointer justify-start gap-3">
<input
v-bind="consentField.inputAttrs.value"
:class="[
'checkbox checkbox-primary',
consentField.isTouched.value && consentField.errorMessage.value && 'checkbox-error',
]"
/>
<span class="label-text">I consent to the processing of my personal data</span>
</label>
<div v-if="consentField.isTouched.value && consentField.errorMessage.value" class="label pt-0">
<span class="label-text-alt text-error">{{ consentField.errorMessage.value }}</span>
</div>
</div>
</div>
<div class="flex items-center justify-between pt-2">
<div class="flex items-center gap-3">
<button type="button" class="btn btn-primary" :disabled="hasVisibleErrors" @click="form.submit()">
Submit
</button>
<button type="button" class="btn btn-ghost" @click="form.reset()">Reset</button>
</div>
<div class="flex items-center gap-4 text-xs">
<span :class="form.isDirty.value ? 'text-primary' : 'text-neutral/50'">
{{ form.isDirty.value ? "Modified" : "Unchanged" }}
</span>
<span :class="form.isValid.value ? 'text-success' : 'text-error'">
{{ form.isValid.value ? "Valid" : "Invalid" }}
</span>
</div>
</div>
<div v-if="props.submitted" class="mt-6 p-4 bg-success/10 border border-success/20 rounded-lg">
<div class="text-sm font-medium text-success mb-2">Submitted Data:</div>
<pre class="text-xs overflow-auto">{{ JSON.stringify(props.submitted, null, 2) }}</pre>
</div>
</div>
</template>
How it works
1 Initialize the form with useLiveForm()
The useLiveForm()
composable takes the form prop and configuration. It returns reactive form state
and field accessors.
const form = useLiveForm(() => props.form, {
changeEvent: "validate",
submitEvent: "submit",
debounceInMiliseconds: 300
})
2 Access fields with form.field()
Each field provides reactive state including value,
errors
(array), errorMessage
(first error shortcut), isTouched,
and inputAttrs
for binding.
const nameField = form.field("name")
// errorMessage.value is equivalent to errors.value[0]
// Use: nameField.inputAttrs.value, nameField.errorMessage.value
3 Error display strategies
You can display validation errors in different ways depending on UX needs.
This example shows errors only after the user has interacted with a field
(isTouched),
which prevents showing errors before the user has had a chance to fill in the form.
<!-- Show error only after user interaction -->
<div v-if="nameField.isTouched.value && nameField.errorMessage.value">
{{ nameField.errorMessage.value }}
</div>
<!-- Always show error (useful after submit) -->
<div v-if="nameField.errorMessage.value">...</div>
<!-- Show all errors for a field -->
<div v-for="error in nameField.errors.value">{{ error }}</div>
4 Server validates with Ecto changeset
The LiveView uses
Ecto.Changeset
for validation. Use
to_form()
to convert the changeset to a form that LiveVue understands.
changeset = changeset(params) |> Map.put(:action, :validate)
assign(socket, form: to_form(changeset, as: :contact))
5 Bind inputs with v-bind and inputAttrs
Use v-bind
with inputAttrs.value
to automatically wire up value, name, id, and event handlers.
<input v-bind="nameField.inputAttrs.value" type="text" />
6 Submit and reset the form
Call form.submit()
to send the submitEvent
to the server, and
form.reset()
to restore initial values and clear touched state.
<button @click="form.submit()" :disabled="!form.isValid.value">
Submit
</button>
<button @click="form.reset()">Reset</button>
7 Reset form state after successful submit
When resetting the form after a successful submission, return
{:reply, %{reset: true}, socket}
instead of {:noreply, socket}.
This tells useLiveForm()
to clear the client-side touched state, preventing validation errors from showing on the fresh form.
def handle_event("submit", params, socket) do
%{"contact" => form_params} = params
changeset = changeset(form_params)
|> Map.put(:action, :insert)
if changeset.valid? do
# Reply with reset: true to clear touched state
new_form = to_form(
changeset(%{}),
as: :contact
)
{:reply, %{reset: true},
assign(socket, form: new_form)}
else
{:noreply,
assign(socket,
form: to_form(changeset, as: :contact))}
end
end