LiveVue 1.0
Examples Simple Form

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

1
useLiveForm()
Form state management
2
Ecto Changeset
Server-side validation
3
Debounced Events
Efficient validation
SimpleForm.vue
<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
Next up: Nested Objects with dot notation paths
View example