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