Andrew Timberlake Andrew Timberlake

Hi, I’m Andrew, a programmer and entrepreneur from South Africa,
building Mailcast for taking control of your email.
Thanks for visiting and reading.


Use a view model with Phoenix LiveView

I was working on the interface for Mailcast, where I wanted to highlight search matches and present a UI overlay for deleted items, with undo support. Each record, then, needed to know if it was selected or deleted.

Mailcast interface with inline search matching and deleting items with undo support.

The problem

For each record, you need to know if it is selected or deleted, and in the case of the deleted items that can be undone, some information to support the undo is needed.

There are a few options you might consider:

  1. You could keep lists of selected and deleted items and then compare them during render.
  2. It would be nicer to be able to use {item.selected} and {item.deleted}, but then you need to litter your model with fields to support the UI state (you would need virtual fields in an Ecto schema).
  3. The third option is to use a view model to decorate the records with the UI state.

Using a view model to decorate records with UI state

Given a basic struct like this:

defmodule MyApp.Item do
  defstruct [:id, :name]
end

A view model can be a simple struct that contains the original model and adds keys to support the UI state.
I duplicate the id field to make it easier for live streams to match the original model.
I’ve included some convenience functions to make it easier to create and manipulate the view model.

defmodule MyApp.ItemView do
  defstruct id: nil, item: nil, selected: false

  @doc """
  Create a new item view model from an item and optional keyword list of options.
  """
  def new(item, opts \\ []) do
    %MyApp.ItemView{
      id: item.id,
      item: item,
      selected: Keyword.get(opts, :selected, false)
    }
  end

  @doc """
  Map a list of items to a list of item view models.
  """
  def map(items) do
    Enum.map(items, &new(&1))
  end
end

In your LiveView, you can wrap records in the view model and then use the view model in the template.

Live Demo

Click on an item to select it and click again to deselect it.

  • Item 1
  • Item 2
  • Item 3
  • Item 4
  • Item 5
  • Item 6
  • Item 7
  • Item 8
  • Item 9
  • Item 10

Below is the source code for the LiveView

defmodule MyApp.SelectedItemsLive do
  use MyApp.Web, :live_view
  alias MyApp.{Item, ItemView}
  @items 1..10 |> Enum.map(fn i -> %Item{id: i, name: "Item #{i}"} end)
  def mount(_params, _session, socket) do
    socket =
      socket
      |> stream_configure(:items, dom_id: &to_string(&1.id))
      |> stream(:items, ItemView.map(@items))

    {:ok, socket}
  end

  def handle_event("select", %{"id" => id}, socket) do
    item = @items |> Enum.find(&(&1.id == id))
    selected_item = ItemView.new(item, selected: true)
    socket = stream_insert(socket, :items, selected_item)
    {:noreply, socket}
  end

  def handle_event("deselect", %{"id" => id}, socket) do
    item = @items |> Enum.find(&(&1.id == id))
    selected_item = ItemView.new(item, selected: false)
    socket = stream_insert(socket, :items, selected_item)
    {:noreply, socket}
  end

  def render(assigns) do
    ~H"""
    <div>
      <ul phx-update="stream" id="items" class="w-fit">
        <li
          :for={{dom_id, item_view} <- @streams.items}
          id={dom_id}
          data-selected={item_view.selected}
          class="px-2 my-1 cursor-pointer data-[selected]:bg-blue-500"
          phx-click={
            JS.push(if(item_view.selected, do: "deselect", else: "select"),
              value: %{id: item_view.id}
            )
          }
        >
          {item_view.item.name}
        </li>
      </ul>
    </div>
    """
  end
end

As for deleting items with undo, I’ll write about how I did that in a future post.

14 Feb 2025

I’m available for hire, whether you need an hour to help you work through a particular problem, or help with a larger project.