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.


How to provide undo delete with Phoenix LiveView

Destructive actions need to be handled carefully, so that a user doesn’t inadvertently lose important data. The most common solution in a web app is to provide a confirmation step before the action is applied, but providing an undo option makes deletion frictionless and safe. This pattern can also be seen in the ability to undo a send operation in email apps.

While I was working on the interface for Mailcast, I wanted to provide an undo option after a user deleted an email alias. To do this, I needed to store the state per item. Rather than littering the model with virtual fields, I decided to use a view model to decorate the records with the UI state.

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

Delayed action in Phoenix LiveView

The first step is to be able to delay an action. In a LiveView, you can use Process.send_after/3 to send a message to a process after a delay. We can send a message to self which will arrive after the specified delay. That message is handled using handle_info/2. The return value is a reference to the timer which we can use to cancel the message if the user cancels the action.

def handle_event("delete", %{"id" => id}, socket) do
  delay = :timer.seconds(5)
  ref = Process.send_after(self(), {:delete, id}, delay)
  # We need to store the ref for each item.
  {:noreply, socket}
end

def handle_info({:delete, id}, socket) do
  # Actually delete the item
  {:noreply, socket}
end

Using a view model to store the state

The first step is to create a view model that will store the state for each item.

Here is the model and the model view we’ll use in the example (see how to use a view model with Phoenix LiveView for more information on how these are used).

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

defmodule MyApp.DeletableItemView do
  defstruct id: nil, item: nil, deleted: false, delete_ref: nil

  @doc """
  Create a new item view model from an item and optional keyword list of options.
  """
  def new(item, opts \\ []) do
    %MyApp.DeletableItemView{
      id: item.id,
      item: item,
      deleted: Keyword.get(opts, :deleted, false),
      delete_ref: Keyword.get(opts, :delete_ref, nil)
    }
  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

We can then update our event handler to use the view model to store the deleted state and the reference to the message for each item.

Storing the reference is not straightforward. The reference is an erlang term (that looks like this when inspected: #Reference<0.3113892246.589037574.221760>), so we need a way to store it as a string to pass to the UI. We can use :erlang.term_to_binary/1 and base encoding, but to decode it on the other side may expose us to injection attacks. To deal with that security issue, we can use Phoenix.Token.sign/4 to encode and decode the reference. Phoenix.Token.sign/4 adds cryptographic protection based on the application secret key that is also used for signing the session cookies.

def handle_event("delete", %{"id" => id}, socket) do
  delay = :timer.seconds(5)
  ref = Process.send_after(self(), {:delete, id}, delay)
  # The token doesn’t need to be valid for long, so we set the max age to a little longer than our undo timeout
  token = Phoenix.Token.sign(socket, "delete_ref", ref, max_age: 60)
  item = # get item
  item_view = ItemView.new(item, deleted: true, delete_ref: token)
  socket = stream_insert(socket, :items, item_view)
  {:noreply, socket}
end

def handle_event("undo_delete", %{"token" => token}, socket) do
  {:ok, ref} = Phoenix.Token.verify(socket, "delete_ref", token)
  Process.cancel_message(ref)
  item = # get item
  item_view = ItemView.new(item, deleted: false, delete_ref: nil)
  socket = stream_insert(socket, :items, item_view)
  {:noreply, socket}
end

Working with a background job

The problem with this approach is that the action is not actually performed until the message is received. If the user moves away from this live view, or refreshes the page, then the deletion will not be performed.

We can make this more robust by using a background job to perform the deletion. In this example I’ll use the Oban library to perform the deletion.
I’m still going to use Process.send_after/3 so that I can update the UI; otherwise, we need our background job to communicate back to the LiveView when the deletion is performed, and that just complicates things unnecessarily.

I’ve included a live demo below along with the source code which includes the full implementation with an Oban background job.

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

Fun game: see if you can delete them all and undo them before they’re gone.

Below is the source code for the LiveView

defmodule MyApp.UndoDeleteItemsLive do
  use MyApp.Web, :live_view

  alias MyApp.DeletableItem, as: Item
  alias MyApp.DeletableItemView, as: 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

  # Schedule a job to delete the item, and mark an item as deleted
  def handle_event("delete", %{"id" => id}, socket) do
    item = @items |> Enum.find(&(&1.id == id))
    delay = 5
    ref = Process.send_after(self(), {:delete, id}, :timer.seconds(delay))
    job = Oban.insert(MyApp.DeleteItemJob.new(%{"id" => id}, schedule_in: {delay, :seconds}))

    # The token doesn’t need to be valid for long, so we set the max age to a little longer than our undo timeout
    # We now store both the job id and the reference in the token

    token = Phoenix.Token.sign(socket, "delete_ref", {id, job.id, ref}, max_age: 60)
    deleting_item = ItemView.new(item, deleted: true, delete_ref: token)

    socket = stream_insert(socket, :items, deleting_item)

    {:noreply, socket}
  end

  # Cancel the delete job, and mark an item as not deleted
  def handle_event("undo_delete", %{"ref" => ref}, socket) do
    {:ok, {item_id, job_id, ref}} = Phoenix.Token.verify(socket, "delete_ref", ref)
    :ok = Oban.cancel_job(job_id)
    Process.cancel_timer(ref)
    item = @items |> Enum.find(&(&1.id == item_id))
    item_view = ItemView.new(item)

    socket = stream_insert(socket, :items, item_view)

    {:noreply, socket}
  end

  # Remove the item from the stream (assume deleted)
  def handle_info({:delete, id}, socket) do
    socket = stream_delete(socket, :items, %ItemView{id: id})
    {: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} class="px-2 my-1">
          <span
            data-deleted={item_view.deleted}
            class="data-[deleted]:line-through data-[deleted]:text-red-500"
          >
            {item_view.item.name}
          </span>
          <button
            :if={!item_view.deleted}
            phx-click={
              JS.push("delete",
                value: %{id: item_view.item.id}
              )
            }
          >
            <.icon name="fa-trash-can" />
          </button>
          <button
            :if={item_view.deleted}
            phx-click={
              JS.push("undo_delete",
                value: %{ref: item_view.delete_ref}
              )
            }
          >
            <.icon name="fa-trash-can-undo" />
          </button>
        </li>
      </ul>
    </div>
    """
  end
end

21 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.