While working on a new project recently, I needed to allow application users to enter a dynamic list of key-value pairs. Not knowing better, I reached for what I knew and built out a React component to dynamically add and remove inputs while also inserting into the websocket to sync the changeset with the server. Then on the server-side, I had to write some additional handlers to process those values. It worked, but it didn’t feel great. I knew there had to be a better way to approach this.

Dynamically Adding and Removing Inputs

I searched the documentation and thought maybe I could do something with an embedded schema for the pairs, but I couldn’t quite seem to get it to work right. ChatGPT wasn’t sure how to do it either, so I decided to ask on the Elixir Slack. I found out I was on the right track; I just needed to combine embeds_many and inputs_for to dynamically add and remove inputs.

This really helped me solve the problem I had, so I thought I’d write up a quick post expanding on the documentation linked above to provide a complete, working example with all the steps to get there from a fresh mix phx.new. You can see the completed example here.

Initial Project Setup

First, we’ll set up a new Phoenix project:

$ mix phx.new dynamic_inputs

This will install and resolve all dependencies, giving us a clean working Phoenix starter project.

Given the fields we have seen in the documentation, we can create a very simple context and schema:

mix phx.gen.live Marketing MailingList mailing_lists title:string

This generator will create fully functional live views for CRUD operations on our MailingList and output the routes we need to add to lib/dynamic_inputs_web/router.ex. For this demo, we’ll just put it under the default scope for the application:

  scope "/", DynamicInputsWeb do
    pipe_through :browser

    get "/", PageController, :home
    live "/mailing_lists", MailingListLive.Index, :index
    live "/mailing_lists/new", MailingListLive.Index, :new
    live "/mailing_lists/:id/edit", MailingListLive.Index, :edit

    live "/mailing_lists/:id", MailingListLive.Show, :show
    live "/mailing_lists/:id/show/edit", MailingListLive.Show, :edit
  end

What We Have So Far

If we navigate to http://localhost:4000/mailing_lists, we’ll see a standard Phoenix list table with a button to add new mailing lists. Click on it, and we get a standard modal dialog asking for a title.

modal dialog with a title input, text that reads “Use this form to manage mailing_list records in your database”

Alright, let’s see what we can do to add name/value pairs to this in the form of a name and email using examples straight from the documentation.

The first change we make is to lib/dynamic_inputs/marketing/mailing_list.ex:

defmodule DynamicInputs.Marketing.MailingList do
  use Ecto.Schema
  import Ecto.Changeset

  schema "mailing_lists" do
    field :title, :string

    embeds_many :emails, EmailNotification, on_replace: :delete do
      field :email, :string
      field :name, :string
    end

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(mailing_list, attrs) do
    mailing_list
    |> cast(attrs, [:title])
    |> cast_embed(:emails,
      with: &email_changeset/2,
      sort_param: :emails_sort,
      drop_param: :emails_drop)
    |> validate_required([:title])
  end

  defp email_changeset(email, attrs) do
    email
    |> cast(attrs, [:email, :name])
    # Possibly some validations here
  end
end

Then next up, we make use of inputs_for in lib/dynamic_inputs_web/mailing_list_live/form_component.ex. For this example, I am only sharing the relevant snippet surrounding the usage of inputs_for; the contents of the file are otherwise unchanged.

      <.simple_form
        for={@form}
        id="mailing_list-form"
        phx-target={@myself}
        phx-change="validate"
        phx-submit="save"
      >
        <.input field={@form[:title]} type="text" label="Title" />

        <.inputs_for :let={ef} field={@form[:emails]}>
          <input type="hidden" name="mailing_list[emails_sort][]" value={ef.index} />
          <.input type="text" field={ef[:email]} placeholder="email" />
          <.input type="text" field={ef[:name]} placeholder="name" />
          <button
            type="button"
            name="mailing_list[emails_drop][]"
            value={ef.index}
            phx-click={JS.dispatch("change")}
          >
            <.icon name="hero-x-mark" class="w-6 h-6 relative top-2" />
          </button>
        </.inputs_for>

        <input type="hidden" name="mailing_list[emails_drop][]" />

        <button type="button" name="mailing_list[emails_sort][]" value="new" phx-click={JS.dispatch("change")}>
          add more
        </button>
        <:actions>
          <.button phx-disable-with="Saving...">Save Mailing list</.button>
        </:actions>
      </.simple_form>

Unfortunately, trying to run with these changes greets us with the following error condition about column emails does not exist:

[error] ** (Postgrex.Error) ERROR 42703 (undefined_column) column m0.emails does not exist

That makes sense; we never defined what to do with those embeds. Reading over the documentation a bit, it looks like assigning the values to a jsonb column type will handle all the serialization logic for us. Given that, we add a new migration:

defmodule DynamicInputs.Repo.Migrations.AddEmails do
  use Ecto.Migration

  def change do
    alter table(:mailing_lists) do
      add :emails, :jsonb
    end
  end
end

This yields us a very ugly but functional modal form that dynamically adds and removes fields.

Gif showing dynamic addition and removal of email and name fields

I am simply not a designer, so I took this heex, ran to ChatGPT, and said, “Can you make this Phoenix LiveView form more compact, put the name and email pairs on the same line together, and put the ‘x’ at the end of those two? Make the form tighter and look more polished.” It returned heex that I think lacks a bit of soul but should fit the bill for most people’s needs.

Form with all the dynamic email and name fields, but in a more compact form

When This Might Be Useful

The documentation example does feel a bit contrived, as one’s first thought might be, “Why not have a recipient table with names, emails, and a many-to-many relationship?” I don’t have an answer there, but I do know that for my scenario, I needed a way to add dynamic attributes to structs, and the names for those attributes vary widely as they’re basically for custom data.

You can see the completed example here which is along with a lot of the small projects I track to learn Elixir and Phoenix deeper. Thankfully, this provided a great native way to solve the interface problem and allowed me to shed all the useless cruft I had built. I hope this turns out useful for a similar situation you may be facing.

Have you encountered similar challenges with dynamic inputs in your applications? How did you solve them? Leave a comment below sharing your experience and any tips you might have for others facing this problem. Your insights could be invaluable to the community!

comments powered by Disqus