LiveVue
1.0
Nested Objects
Access nested form fields using
dot notation
paths. Perfect for forms with embedded objects like addresses, profiles, or settings.
What this example shows
<script setup lang="ts">
import { useLiveForm, type Form } from "live_vue"
type ProfileForm = {
name: string
email: string
address: {
street: string
city: string
zip: string
}
}
const props = defineProps<{ form: Form<ProfileForm>; submitted: ProfileForm | null }>()
const form = useLiveForm(() => props.form, {
changeEvent: "validate",
submitEvent: "submit",
debounceInMiliseconds: 300,
})
const nameField = form.field("name")
const emailField = form.field("email")
const streetField = form.field("address.street")
const cityField = form.field("address.city")
const zipField = form.field("address.zip")
</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="pt-4 border-t border-base-300">
<div class="text-sm font-medium mb-4">Address</div>
<div class="flex flex-col gap-6">
<label class="form-control w-full">
<div class="label pb-2"><span class="label-text font-medium">Street</span></div>
<input
v-bind="streetField.inputAttrs.value"
type="text"
placeholder="123 Main St"
:class="[
'input input-bordered w-full',
streetField.isTouched.value && streetField.errorMessage.value && 'input-error',
]"
/>
<div v-if="streetField.isTouched.value && streetField.errorMessage.value" class="label">
<span class="label-text-alt text-error">{{ streetField.errorMessage.value }}</span>
</div>
</label>
<div class="grid grid-cols-2 gap-4">
<label class="form-control w-full">
<div class="label pb-2"><span class="label-text font-medium">City</span></div>
<input
v-bind="cityField.inputAttrs.value"
type="text"
placeholder="San Francisco"
:class="[
'input input-bordered w-full',
cityField.isTouched.value && cityField.errorMessage.value && 'input-error',
]"
/>
<div v-if="cityField.isTouched.value && cityField.errorMessage.value" class="label">
<span class="label-text-alt text-error">{{ cityField.errorMessage.value }}</span>
</div>
</label>
<label class="form-control w-full">
<div class="label pb-2"><span class="label-text font-medium">ZIP</span></div>
<input
v-bind="zipField.inputAttrs.value"
type="text"
placeholder="94102"
:class="[
'input input-bordered w-full',
zipField.isTouched.value && zipField.errorMessage.value && 'input-error',
]"
/>
<div v-if="zipField.isTouched.value && zipField.errorMessage.value" class="label">
<span class="label-text-alt text-error">{{ zipField.errorMessage.value }}</span>
</div>
</label>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between pt-2">
<div class="flex items-center gap-3">
<button type="button" :disabled="!form.isValid.value" class="btn btn-primary" @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 Define an embedded schema or relation
Use embeds_one
or has_one/belongs_to to define nested objects. Each gets its own changeset and validation.
defmodule Address do
use Ecto.Schema
@derive LiveVue.Encoder
embedded_schema do
field :street, :string
field :city, :string
field :zip, :string
end
end
embeds_one :address, Address, on_replace: :update
2 Use cast_embed for validation
Call
cast_embed(:address)
to automatically validate nested fields. Errors are mapped to the correct nested paths.
def changeset(profile, attrs) do
profile
|> cast(attrs, [:name, :email])
|> validate_required([:name, :email])
|> cast_embed(:address, with: &Address.changeset/2)
end
3 Access nested fields with dot notation or chaining
Use
form.field("address.city")
or chain with .field().
Both have full TypeScript support for type-safe paths. Use `field[index]` to access nested array fields, eg `form.field("tags[0].name")`.
// Dot notation
const cityField = form.field("address.city")
// Or chain .field() calls
const addressField = form.field("address")
const cityField = addressField.field("city")
4 Bind inputs exactly like flat fields
Once you have a field reference, use it exactly like any other field.
The inputAttrs, errorMessage, and
isTouched
work identically.
<input v-bind="cityField.inputAttrs.value" type="text" />
<div v-if="cityField.errorMessage.value">
{{ cityField.errorMessage.value }}
</div>