LiveVue 1.0
Examples Nested Objects

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

1
Dot Notation
form.field("address.city")
2
Nested Validation
Errors per nested field
3
Flat Params
Nested data sent as nested map
nested_form_live.ex
defmodule MyAppWeb.NestedFormLive do
  use MyAppWeb, :live_view

  import Ecto.Changeset

  defmodule Address do
    use Ecto.Schema
    import Ecto.Changeset

    @derive LiveVue.Encoder
    @primary_key false
    embedded_schema do
      field :street, :string
      field :city, :string
      field :zip, :string
    end

    def changeset(address, attrs) do
      address
      |> cast(attrs, [:street, :city, :zip])
      |> validate_required([:street, :city, :zip])
      |> validate_length(:street, min: 3, max: 100)
      |> validate_length(:city, min: 2, max: 50)
      |> validate_format(:zip, ~r/^\d{5}$/, message: "must be 5 digits")
    end
  end

  defmodule Profile do
    use Ecto.Schema
    import Ecto.Changeset

    @derive LiveVue.Encoder
    @primary_key false
    embedded_schema do
      field :name, :string
      field :email, :string
      embeds_one :address, Address, on_replace: :update
    end

    def changeset(profile, attrs) do
      profile
      |> cast(attrs, [:name, :email])
      |> validate_required([:name, :email])
      |> validate_length(:name, min: 2, max: 50)
      |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must be a valid email")
      |> cast_embed(:address, with: &Address.changeset/2)
    end
  end

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

  def mount(_params, _session, socket) do
    profile = %Profile{address: %Address{}}
    changeset = Profile.changeset(profile, %{})
    {:ok, assign(socket, form: to_form(changeset, as: :profile), submitted: nil)}
  end

  def handle_event("validate", %{"profile" => params}, socket) do
    profile = %Profile{address: %Address{}}

    changeset =
      profile
      |> Profile.changeset(params)
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, form: to_form(changeset, as: :profile))}
  end

  def handle_event("submit", %{"profile" => params}, socket) do
    profile = %Profile{address: %Address{}}

    changeset =
      profile
      |> Profile.changeset(params)
      |> Map.put(:action, :insert)

    if changeset.valid? do
      data = Ecto.Changeset.apply_changes(changeset)

      {:reply, %{reset: true},
       socket
       |> assign(submitted: data)
       |> assign(
         form: to_form(Profile.changeset(%Profile{address: %Address{}}, %{}), as: :profile)
       )}
    else
      {:noreply, assign(socket, form: to_form(changeset, as: :profile))}
    end
  end
end

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>
Next up: Dynamic Arrays with fieldArray()