<!-- LLM_VERSION_INFO
FORMAT: text/markdown
CONTENT_TYPE: article
ORIGINAL_URL: https://fly.io/phoenix-files/liveview-multi-select
ALTERNATE_VERSION: phoenix-files/liveview-multi-select/index.html (text/html)
EXTRACTION_DATE: 2026-04-18T22:26:11.382Z

This is the markdown version with text-only content (images converted to alt-text).
For rich formatting with images, request the HTML version at: phoenix-files/liveview-multi-select/index.html
-->

# Building a Reusable Phoenix LiveView Multi-Select Component

Have you ever wanted a feature that lets your users select multiple items from a list, and performs some action on their selection? This is a common feature to build. Today we’ll walk through building a reusable Phoenix LiveView multi-select component, and hook it up to let users filter a book collection by category. A single book can be both a “Romance” and a “Thriller” at the same time. We want to let users choose any combination of categories using the multi-select component we’re about to build.

## Roadmap

We’ll define our component using the `Phoenix.LiveComponent` module, so it can manage its own state and events. We want this component to:

- **Display a list of selectable options**: Use `embedded_schema` to model the selectable options and keep track of the state. We’ll use `Phoenix.HTML.Form` helpers to render the HTML inputs for selecting and deselecting the options.

- **Show the options we’ve already chosen**: Define a `selected_options` assign to track and render the component’s selections.

- **Send selection updates to the parent LiveView**: Each time an option is selected, the multi-select component should inform the parent LiveView of any changes to the selection. This will happen using `phx-change` to emit an event when an input has changed.

- **Update when the selection changes**: Take advantage of the LiveComponent life cycle to propagate updates and render changes.

- **Show/hide the list of selectable options**: Use the `JS.toggle` command to hide and show the options list.

Once we finish our work, we’ll have this component ready to be used!

## Creating a LiveComponent

We start by defining a skeleton `LiveComponent`—which we name `MultiSelectComponent`—with placeholder `render/1` and `update/2` callbacks.

```elixir
defmodule PhoenixFilesWeb.MultiSelectComponent do
  use PhoenixFilesWeb, :live_component

alias PhoenixFiles.MultiSelect

def render(assigns) do
    ~H"""
    <div>

</div>
    """
  end

def update(%{id: id} = params, socket) do
    {:ok, assign(socket, :id, id)}
  end
end
```

## Defining a data model

Each option in our list needs a label to display, and some way to keep track of whether it is selected or not.

```elixir
defmodule PhoenixFiles.MultiSelect do
  use Ecto.Schema

embedded_schema do
    embeds_many :options, PhoenixFiles.MultiSelect.SelectOption
  end

defmodule SelectOption do
    use Ecto.Schema

embedded_schema do
      field :selected, :boolean, default: false
      field :label, :string
    end
  end
end
```

This defines an `embedded schema` called `MultiSelect`, where the `:options` field embeds a list of `SelectOption` schemas.

## Rendering the selectable options within a form

The `update/2` function adds `:selectable_options` (a list of `SelectOption` schemas) and a yet-to-be-defined enclosing `:form` to the component’s assigns.

```elixir
def update(params, socket) do
  %{options: options, form: form, id: id} = params
  socket =
    socket
    |> assign(:id, id)
    |> assign(:selectable_options, options)
    |> assign(:form, form)

{:ok, socket}
end
```

Then `render/1` uses those assigns to put the pieces together in our template:

```elixir
def render(assigns) do
  ~H"""
  <div class="multiselect">
    <div class="fake_select_tag"
      id={"#{@id}-selected-options-container"}>
      ...
    </div>
    <div id={"#{@id}-options-container">
      <%= inputs_for @form, :options, fn opt -> %>
        <div class="form-check">
          <div class="selectable-option">
            <%= checkbox opt, :selected,
              value: opt.data.selected
            %>
            <%= label opt, :label, opt.data.label %>
          </div>
        </div>
      <% end %>
    </div>
  </div>
  """
end
```

The main feature here is our `inputs_for/4` function, which attaches our nested `:options` data to the form and iterates over the options, invoking the `checkbox/3` and `label/3` functions to render those elements.

## Displaying the set of selected options

We want to display a list of selected options. We write a private function `filter_selected_options` to find all the `SelectOptions` with `selected == true`, and add these to our assigns:

```elixir
def update(params, socket) do
  %{options: options, form: form, id: id} = params
  socket =
    socket
    |> assign(:id, id)
    |> assign(:selectable_options, options)
    |> assign(:form, form)
    |> assign(:selected_options, filter_selected_options(options))

{:ok, socket}
end

defp filter_selected_options(options) do
  Enum.filter(options, fn opt ->
    opt.selected in [true, "true"]
  end)
end
```

We can iterate over `@selected_options` and display their labels:

```elixir
<div class="fake_select_tag"
  id={"#{@id}-selected-options-container"}>
  <%= for option <- @selected_options do %>
    <div class="selected_option">
      <%= option.label %>
    </div>
  <% end %>
  <div class="icon">
    ...
  </div>
</div>
```

## Sending selection updates

To update the checkboxes, we need to emit an event every time items are selected or deselected:

```elixir
<%= checkbox value, :selected,
  value: value.data.selected,
  phx_change: "checked",
  phx_target: @myself
%>
```

Now let’s see how to handle the `checked` event:

```elixir
def handle_event(
      "checked",
      %{"multi_select" => %{"options" => values}},
      socket
    ) do

[{index, %{"selected" => selected?}}] = Map.to_list(values)
  index = String.to_integer(index)
  selectable_options = socket.assigns.selectable_options
  current_option = Enum.at(selectable_options, index)

updated_options =
    List.replace_at(selectable_options,
      index,
      %{current_option | selected: selected?}
    )

send(self(), {:updated_options, updated_options})

{:noreply, socket}
end
```

When the event is emitted, it tells us when a selection change was made.

## Showing and hiding the selectable options

We can add a chevron-up icon to match the chevron-down—to toggle visibility of icons and options list.

```elixir
<div class="icon">
  <svg id={"#{@id}-down-icon"}
    phx-click={
      JS.toggle()
      |> JS.toggle(to: "##{@id}-up-icon")
      |> JS.toggle(to: "##{@id}-options-container")
    }>
    <path ... />
  </svg>
  <svg id={"#{@id}-up-icon" class="hidden"
    phx-click={
      JS.toggle()
      |> JS.toggle(to: "##{@id}-down-icon")
      |> JS.toggle(to: "##{@id}-options-container")
    }>
    <path .... />
  </svg>
</div>
```

## MultiSelectComponent in action

Now we define the assigns we use in the parent LiveView:

```elixir
def mount(_params, _session, socket) do
  categories =
    [
      %SelectOption{id: 1, label: "Fantasy", selected: false},
      %SelectOption{id: 2, label: "Horror", selected: true},
      %SelectOption{id: 3, label: "Literary Fiction", selected: false},
    ]

{:ok, set_assigns(socket, categories)}
end
```

`categories` contains our category options in the shape of the `SelectOption` schema. Our `set_assigns/2` function sets assigns we’ll need:

```elixir
defp set_assigns(socket, categories) do
  socket
  |> assign(:changeset, build_changeset(categories))
  |> assign(:books, filter_books(categories))
  |> assign(:categories, categories)
end
```

We create a `MultiSelect` changeset with the `build_changeset/1` function:

```elixir
defp build_changeset(options) do
  %MultiSelect{}
  |> Ecto.Changeset.change()
  |> Ecto.Changeset.put_embed(:options, options)
end
```

Once the changeset is created, we use it to create our `form`:

```elixir
<.form let={f} for={@changeset} id="multiselect-form">
</.form>
```

Finally, we render our `MultiSelectComponent` inside our form:

```elixir
<.form let={f} for={@changeset} id="multiselect-form">
  <.live_component
    id="multi"
    module={MultiSelectComponent}
    options={@categories}
    form={f}
  />
</.form>
```

When a category is selected in the `MultiSelectComponent`, an update is sent to the parent LiveView:

```elixir
def handle_info({:updated_options, options}, socket) do
  # update books list, the selected categories and the changeset
  {:noreply, set_assigns(socket, options)}
end
```

## Bonus: Form recovery

To avoid losing the selected options, we will make use of form recovery after reconnections. We mark our form with the `phx-change` binding:

```elixir
<.form
  let={f}
  for={@changeset}
  id="multiselect-form"
  phx-change="validate"
>
  ...
</.form>
```

```elixir
def handle_event(
      "validate",
      %{"multi_select" => multi_form},
      socket
    ) do
  options = build_options(multi_form["options"])

{:noreply, set_assigns(socket, options)}
end
```

## Bonus 2: Customized behavior when options are selected.

We send a function to execute when an element is selected:

```elixir
<.live_component
  id="multi"
  module={MultiSelectComponent}
  options={@categories}
  form={f}
  selected={fn opts -> send(self(), {:updated_options, opts}) end}
/>
```

## Wrap-Up

We designed a solid base to reuse our component and customize it for our needs. The LiveView structure allows us to keep both the parent and MultiSelectComponent in sync effectively.

Where else would you use this multi-select component? You can find this example here: [bemesa21/phoenix_files](https://github.com/bemesa21/phoenix_files/)
