Managing One-to-Many Relationships in a LiveView Form

Hello Elixir community,

I’m relatively new to Elixir and Phoenix, and I’m looking for guidance in lieu of finding a relevant guide that it still up to date.

I’m seeking guidance and advice on how to insert multiple related records into a database within a single transaction, using data collected from a single form. My data model includes Properties and their associated Rooms, structured in a one-to-many relationship.

Since every property must have at least one room, I’d like to design a form that captures all necessary data for both entities in one go. I am still relatively new to Elixir and haven’t been able to find if this is a recommended approach or any guidance on how I can improve my code.

Here’s the relevant code:

# In lib/my_app/listings/property

schema "properties" do
    field :address, :string
    has_many :rooms, MyApp.Listings.Room

    timestamps(type: :utc_datetime)
end

  @doc false
  def changeset(property, attrs) do
    property
    |> cast(attrs, [:address])
    |> validate_required([:address])
    |> cast_assoc(:rooms, required: true)
  end

# In lib/my_app/listings/room

schema "rooms" do
    field :size, :integer
    belongs_to :property, MyApp.Listings.Property

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(room, attrs) do
    room
    |> cast(attrs, [:size])
    |> validate_required([:size])
  end

# In web/live/listing_live/form.ex

def render(assigns) do
    ~H"""
    <h1>New Property</h1>

    <.simple_form for={@form} id="property-form" phx-submit="save">
      <.input field={@form[:address]} type="text" label="Property Address" />

	<h2>Rooms</h2>
      <.inputs_for :let={room_form} field={@form[:rooms]}>
        <div>
          <.input field={room_form[:size]} type="number" label="Room Size (sq ft)" />
        </div>
      </.inputs_for>

      <:actions>
        <.button phx-disable-with="Saving...">Save Listing</.button>
      </:actions>
    </.simple_form>
    """
  end

  def mount(_params, _session, socket) do
    changeset = Listings.change_property(%Property{})
    {:ok, assign(socket, :form, to_form(changeset))}
  end

  def handle_event("save", %{"property" => params}, socket) do
    case Listings.create_property(params) do
      {:ok, _property} ->
        {:noreply,
         socket
         |> put_flash(:info, "Property created successfully")
         |> redirect(to: ~p"/properties")}

      {:error, changeset} ->
        {:noreply, assign(socket, changeset: changeset)}
    end
  end

Is this the correct way to achieve the goal outlined above, or is there another way that is more aligned with the recommended best practices?

How would I go about updating this to add/remove information about more than one room?

Should I be aiming to use the same form for creating and editing, and what changes would I need to consider making to enable this?

Thank you in advance for your time and expertise!

Whilst the guide is titled many-to-many, it covers your use case too.

The bits of intrest will be the sort_param and delete_param.

As per the docs, you can use sort_param to add new ‘rooms’ to your property.

Check out the Checkboxes magic section of the fly guide.

Essentially it uses a checkbox disguised as a button to make ecto add a new room to the rooms assoc.

EDIT: See reply below, you can use a button rather than ‘checkbox magic’ - but otherwise its structured the same way


Alternately, you can create a separate button that can manipulate the form (which is needed if you need your new ‘room’ has pre-filled data that is generated on the fly (ie you can’t use default: in the schema). Ie something like the following

handle_event("add_field", _params, socket) do
  update(socket, :form, fn %{source: changeset} -> 
    room = %{address: "123"}

    existing_rooms = get_assoc(changeset, :rooms)
    changeset = put_assoc(changeset, :rooms, [room | existing_rooms])

    to_form(changeset)
  end)
end

An (admittedly now old) guide on this method: One-to-Many LiveView Form | Benjamin Milde

However id suggest if you can use sort_param, do that instead.

1 Like

The latest docs no longer use checkboxes, but buttons with LV. The checkboxes are only necessary for usage without LV:

https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#inputs_for/1-dynamically-adding-and-removing-inputs

3 Likes

Oh nice, I hadn’t spotted this! I got some projects I should update then… :sweat_smile:

Thank you both for providing the insightful links and comments. I’ve carefully reviewed them and updated my implementation based on my understanding. Below are the changes I made, and I would greatly appreciate any further feedback or suggestions:

  1. Added on_replace: :delete to the property schema’s rooms relationship.

  2. Updated the cast_assoc arguments to include with, sort_param, and drop_param. I’m unsure whether I need to add a new field to store the position or if using :rooms_sort and :rooms_drop is sufficient?

  3. Modified the form to align with the example provided in the linked LiveView documentation, although I don’t feel I fully grasp the implications of these changes.

  4. Added an add_room event handler to append a new room_changeset to the rooms data in the socket.

Updated code:

# In lib/my_app/listings/property

schema "properties" do
    field :address, :string
    has_many :rooms, MyApp.Listings.Room, on_replace: :delete

    timestamps(type: :utc_datetime)
end

@doc false
def changeset(property, attrs) do
    property
    |> cast(attrs, [:address])
    |> validate_required([:address])
    |> cast_assoc(:rooms, 
      with: &Room.changeset/2,
      sort_param: :rooms_sort, 
      drop_param: :rooms_drop, 
      required: true
    )
end

# In web/live/listing_live/form.ex

def render(assigns) do
    ~H"""
    <h1>New Property</h1>

    <.simple_form for={@form} id="property-form" phx-submit="save">
        <.input field={@form[:address]} type="text" label="Property Address" />

        <.inputs_for :let={rf} field={@form[:rooms]}>
            <input type="hidden" name="rooms[rooms_sort][]" value={rf.index} />
            <.input type="text" field={rf[:size]} label="Room Size (sq ft)" />
            <button
                type="button"
                name="rooms[rooms_drop][]"
                value={rf.index}
                phx-click={JS.dispatch("change")}
            >
            </button>
        </.inputs_for>

        <input type="hidden" name="rooms[rooms_drop][]" />

        <button type="button" phx-click="add_room">
            Add Room
        </button>

        <:actions>
            <.button phx-disable-with="Saving...">Save Listing</.button>
        </:actions>
    </.simple_form>
    """
end

def mount(_params, _session, socket) do
    changeset = Listings.change_property(%Property{})
    {:ok, assign(socket, :form, to_form(changeset))}
end

def handle_event("save", %{"property" => params}, socket) do
    case Listings.create_property(params) do
      {:ok, _property} ->
        {:noreply,
         socket
         |> put_flash(:info, "Property created successfully")
         |> redirect(to: ~p"/properties")}

      {:error, changeset} ->
        {:noreply, assign(socket, changeset: changeset)}
    end
end

def handle_event("add_room", _, socket) do
    socket =
      update(socket, :form, fn %{source: changeset} ->
        existing = Ecto.Changeset.get_field(changeset, :rooms) || []
        changeset = Ecto.Changeset.put_assoc(changeset, :rooms, existing ++ [%Room{}])
        to_form(changeset)
      end)
  
    {:noreply, socket}
end

Based on the references provided and my understanding of the embedded schema documentation, would using an embedded schema be a better fit for my use case?

  1. Whilst I’m not sure its required, it doesn’t harm in having the extra hidden input which stores the index. If you ever plan to have the rooms orderable you will need it as it helps ecto identify the row

  2. (and 4.) The following extract from the docs is essentially the butter behind how sort_param works:

:sort_param - the parameter name which keeps a list of indexes to sort from the relation parameters. Unknown indexes are considered to be new entries. Non-listed indexes will come before any sorted ones.

Pressing the button adds the ‘new’ index to the list of rooms. As this is an unknown index, it create a new entry.


I don’t want to give you information overload, but I also want to be some-degree of helpful so ill do my best to be somewhere in the middle.

In regards to your final comment, embedded schemas are useful for if you don’t want to create a table in, lets say postgres.

If you do want to save the value (rooms) as a ‘json blob’ on the row (property), you can use either embed_one or embed_many in combination with an embedded schema.

But the TLDR is that it doesn’t matter if you using an has_many or embed_many, the LV it’s still the same. Still an inputs_for, still calling the changeset on the schema/embedded schema.


Taking a look at your snippet of code, I’ll go through bit by bit.

  1. The schema looks good. You are correct to use the on_replace as the docs say. Using a has_many is perfectly fine.

  1. The changeset looks mostly good, correctly using cast_assoc for your rooms has_many, however the required: true is not necessary. I don’t think that’s a valid option either(?).

  1. Moving onto the render, firstly we have the form.

<.simple_form for={@form} id="property-form" phx-submit="save">

You should add a phx-change="validate" attribute. This will call a handle_event when any input in the form is changed. This is required so you can update the changeset and the form in the socket. Docs

The handle event would look something like this:

def handle_event("validate", %{"properties" => params}, socket) do
  form =
    %Property{}
    |> Property.changeset(params)
    |> to_form(action: :validate)

  {:noreply, assign(socket, form: form)}
end

In this case, we are assuming you are creating a new property so we are creating a fresh %Property{}, and passing in the latest params of the form. action: :validate runs any validations (ie your |> validate_required([:address])). In my example at the bottom of my response, I have included an example of pulling the property from the socket making this compatible with both creating and viewing existing properties.

Adding the attribute above is important, as your buttons call the phx-change attribute. See the below

            <button
                type="button"
                name="rooms[rooms_drop][]"
                value={rf.index}
                phx-click={JS.dispatch("change")}
            >
            </button>

Specifically, phx-click={JS.dispatch("change")}

phx- is automatically pre-pended onto the change, calling the function above you have just created.

(it’s tucked away in the docs admittedly…)


  1. However, the name you have used for this button’s and hidden inputs is incorrect. It should be name="properties[rooms_drop][]", rather than name="rooms[rooms_drop][]". Same for sort, name="properties[rooms_sort][]", rather than name="rooms[rooms_sort][]".

  1. Finally, moving onto the add_room button. As your ‘room’ is rather unexciting, we can just use sort_param. We would only need to use phx-click="add_room" and the corresponding handle_event if you are doing some more complicated things with setting defaults of the room. And in this case, you are (currently) not.

So put that to the back of your head for now, and replace it with this

<button type="button" name="properties[rooms_sort][]" value="new" phx-click={JS.dispatch("change")}>
  Add Room
</button>

You can now delete the def handle_event("add_room", _, socket) do function as you won’t be needing it.


And that’s it, it should now add the room when you hit the button :slight_smile:

Should anyawy…

# In lib/my_app/listings/property
alias MyApp.Listings.Room

schema "properties" do
    field :address, :string
    has_many :rooms, Room, on_replace: :delete

    timestamps(type: :utc_datetime)
end

@doc false
def changeset(property, attrs) do
    property
    |> cast(attrs, [:address])
    |> validate_required([:address])
    |> cast_assoc(:rooms, 
      with: &Room.changeset/2,
      sort_param: :rooms_sort, 
      drop_param: :rooms_drop
    )
end

# In web/live/listing_live/form.ex

def render(assigns) do
    ~H"""
    <h1>New Property</h1>

    <.simple_form for={@form} id="property-form" phx-submit="save" phx-change="validate">
        <.input field={@form[:address]} type="text" label="Property Address" />

        <.inputs_for :let={rf} field={@form[:rooms]}>
            <input type="hidden" name="properties[rooms_sort][]" value={rf.index} />
            <.input type="text" field={rf[:size]} label="Room Size (sq ft)" />
            <button
                type="button"
                name="properties[rooms_drop][]"
                value={rf.index}
                phx-click={JS.dispatch("change")}
            >
            </button>
        </.inputs_for>

        <input type="hidden" name="properties[rooms_drop][]" />

        <button type="button" name="properties[rooms_sort][]" value="new" phx-click={JS.dispatch("change")}>
          Add Room
        </button>

        <:actions>
            <.button phx-disable-with="Saving...">Save Listing</.button>
        </:actions>
    </.simple_form>
    """
end

def mount(_params, _session, socket) do
    property = %Property{} # in future, you could Repo.get this for when 'viewing' a property
    changeset = Listings.change_property(property)
    {:ok, assign(socket, property: property, form: to_form(changeset))}
end

def handle_event("validate", %{"properties" => params}, socket) do
  form =
    socket.assigns.property
    |> Property.changeset(params)
    |> to_form(action: :validate)

  {:noreply, assign(socket, form: form)}
end

def handle_event("save", %{"property" => params}, socket) do
    case Listings.create_property(params) do
      {:ok, _property} ->
        {:noreply,
         socket
         |> put_flash(:info, "Property created successfully")
         |> redirect(to: ~p"/properties")}

      {:error, changeset} ->
        {:noreply, assign(socket, changeset: changeset)}
    end
end
1 Like

Are you sure about that? Doesn’t JS.dispatch("change") dispatch a regular HTMLEvent called "change" event, which is captured by one of LiveView’s event handlers, which pushes a LiveView event down the WebSocket to the backend?

Yes, you are right. My bad!

Thank you all for your helpful feedback. I apologise for the delayed response - I took some time to review the fundamentals and strengthen my understanding.

@joshua-bouv : Your explanation of the differences between associations and embeddings was particularly helpful. I’d like to confirm my understanding: Would embeddings be the appropriate choice for implementing comment functionality on entities like news articles or videos, since comments are typically only accessed in the context of their parent post?

The code is now working as expected, and I feel I have a solid grasp of the concepts. Thank you all for guiding me in the right direction.

I have a follow-up question about code organization as my form grows more complex: What’s the recommended approach for structuring nested input fields? I’m considering moving them into a live component, but I’m unsure about state management - should the form state remain at the top level, or should it be managed within the component itself? I’m particularly interested in hearing about the tradeoffs involved in each approach.

Thanks again!

Is there a benefit to using buttons over checkboxes? Just more idiomatic HTML?

Yeah, also less problematic to get them to look like buttons :slight_smile:

Gotcha. Well, I just published a guide using checkboxes so I’ll go fix that :laughing: Its a pretty simple drop in replacement though so no big deal. (a guide for Ash I mean)

Not wrong to have that though given that’s the version which works for non LV usecases. Needing phx-click makes this a LV only option, which makes sense when being presented in the LV docs.

Good point.