Growth

How to Add Undo and a Trash Folder So Users Never Lose Work

November 2, 2026
13 min read

AI-friendly Markdown · structured for AI citations

How to Add Undo and a Trash Folder So Users Never Lose Work

How to add undo and a trash folder: undo messages instead of confirm dialogs, soft delete with deleted_at, a 30-day trash, version history and a purge job.

To add undo and a trash folder so users never lose work, stop asking "Are you sure?" before everyday deletes. Do the action straight away, show a short message with an Undo button, and keep the deleted item in a trash folder for a fixed window, such as 30 days, before a scheduled job removes it for good. Under the hood that usually means soft delete: a deleted_at timestamp instead of a real DELETE, with every normal query filtering those rows out. For anything people spend real time writing, also save versions they can restore. Keep confirmation dialogs for the few actions you truly cannot take back, like sending an email, charging a card or deleting a whole workspace.

This guide is for solo makers and tiny teams whose users can wipe out an hour of work with one click, then email support begging for it back. The worked example near the end is made up. Library and vendor details come from their own docs, linked below and checked on 2 November 2026. Nothing here is legal advice.

Why don't "Are you sure?" dialogs protect users?

Because people stop reading them. A confirmation that appears every time someone deletes a row trains them to click Yes without looking. Jakob Nielsen's article on confirmation dialogs that prevent errors only when they are not overused puts it plainly: if you cry wolf too often, people stop paying attention, and an automatic Yes gives no protection at all. The same article ends by telling designers to go to great lengths to provide undo, because some mistakes get through even the best dialog.

A dialog also asks at the worst moment. The user just decided to delete, so of course they say yes. Regret arrives later, when they notice the thing is gone. Undo meets them at that later moment; a dialog cannot. For routine actions, act first and offer a way back second.

When is a confirmation dialog still the right call?

Keep a confirmation when the action has effects you cannot pull back from your own database:

  • Something leaves your system. An email goes to a customer, a webhook fires, a post goes public.

  • Money moves. A charge, a refund, a plan change that bills right away.

  • The blast radius is huge. Deleting a whole workspace or an account.

When you do confirm, make it worth reading. Nielsen's guidelines are practical: say exactly what will happen (the item's name, how many records), label the buttons with the outcome, such as Delete project and Keep project, rather than Yes and No, and do not make the dangerous option the default. For the rarest, most dangerous actions he suggests a nonstandard step, like typing a word into a box; asking someone to type the workspace name works the same way. Save it for the truly big actions, or it becomes one more habit.

WCAG success criterion 3.3.4, explained in W3C's page on error prevention for legal, financial and data actions, says that when a page deletes user-controllable data, the action should be reversible, checked, or confirmed. Undo plus a trash folder covers that for everyday deletes.

What should an undo message say and do?

The undo message (a toast or snackbar) is the face of the whole system:

  • Name what happened. "Deleted 'Kitchen shoot, day 2'" beats "Item deleted". For bulk actions, give the count. One button: Undo.

  • Stay long enough to be useful. There is no magic number. For reference, Gmail's Undo Send setting lets people choose a cancellation period of 5, 10, 20 or 30 seconds. Pick a window, keep the message up while the pointer or focus is on it, and say where else to find the item ("In Trash for 30 days").

  • Reachable without a mouse. The Undo button must be focusable. Announce the message to screen readers with a live region; MDN's page on the ARIA status role explains that role="status" is announced politely and that you should not move focus to it.

  • Do not stack. Several quick deletes should replace or combine messages ("Deleted 5 items").

If your product already behaves like an editor, Cmd+Z or Ctrl+Z should undo the last action too. If an undo fails (the item was already purged, or a teammate edited it), say so in plain words and say what to do next; the MakerHunt guide to writing error messages that keep users moving has the pattern for that message.

One undo or a full history?

Most products need one level: undo the last destructive action, and let the trash handle anything older. A multi-step undo stack belongs in editors (documents, canvases, spreadsheets). For a task list, trash plus version history already covers "I need that back from Tuesday".

How does undo actually work under the hood?

There are two ways, and most products use both.

Wait before doing the real thing

Mark the action as pending and carry it out only after the undo window closes. Undo cancels the pending job. This is the only option for outside effects like sending a message, because an email cannot be pulled back once it reaches someone else's server. Queue the job on the server, not in a browser timer, so it still runs if the tab closes.

Do it now and keep a way back

For data in your own database, act immediately but keep it reversible. A delete becomes "set a timestamp", an edit keeps the previous version, and Undo puts the old state back. This also gives you the trash folder for free.

How do you add soft delete without breaking everything?

Soft delete means adding a nullable deleted_at column. Deleting sets it to the current time; restoring sets it back to null; every normal query adds WHERE deleted_at IS NULL. The bugs live in the details:

  1. The "forgot the filter" bug. One query without the filter and deleted items reappear in a dashboard count, a search, an export or a weekly email. Put the filter in one place every read goes through (a default scope, a view or a shared query function) and search the code for raw queries.

  2. Unique constraints. A user deletes the project "Website" and creates a new one with the same name. A plain unique index rejects it, because the deleted row still holds the name. In Postgres, a partial unique index enforces uniqueness only for rows matching a condition, so CREATE UNIQUE INDEX projects_name_live ON projects (account_id, name) WHERE deleted_at IS NULL lets the live name be unique while trashed copies sit alongside. If the name is taken again at restore time, rename it "Website (restored)" rather than failing.

  3. Child records. A trashed project's tasks should disappear and come back with it. The simplest approach is to hide children whose parent is deleted rather than stamping every child; the README of the discard gem for Rails argues for exactly this. Database cascades fire only on a real delete, so they will not do it for you.

Your framework may already help. Laravel's Eloquent has built-in soft deleting through a SoftDeletes trait: deleted rows are excluded from queries automatically, and you get withTrashed(), onlyTrashed(), restore() and forceDelete(). In Rails, discard adds a discarded_at column with explicit kept and discarded scopes and recommends against a default scope. The older paranoia gem now says in its own README that it is not recommended for new projects because it overrides delete and destroy.

A yellow pencil's eraser rubbing out marks on white paper, with eraser crumbs scattered around

What should a trash folder look like?

Whether you call it a trash folder or a recycle bin, copy the version people already know. Google Drive's help page on deleting files describes it: files moved to Trash are deleted forever after 30 days, Empty trash deletes them immediately, and files in Trash still count toward storage. Your version needs:

  • Deleted items with name, who deleted it and when, plus search once the list grows.

  • Days left on each row: "Deletes in 12 days".

  • Restore to the original place. If the parent is also deleted, restore both or ask where it should go.

  • Delete forever and Empty trash. These deserve a specific confirmation, because they really cannot be undone.

In a team product, a simple rule works: anyone who could see the item can restore it, and only owners can delete forever.

Support will still get "can you get it back?" requests. A restore button in your internal tools beats running SQL by hand; see this guide to building a simple admin panel for your own product for where that button fits.

Do you need version history as well?

Yes, for anything people write. Pasting over a paragraph, a teammate's edit or an overconfident AI rewrite can destroy an hour of writing while the item still exists. Trash cannot help there; version history can. A cheap version:

  • Autosave every few seconds while someone types.

  • Snapshot into a versions table when editing pauses, and before any big change you make for the user, such as an import or AI rewrite.

  • List versions with time, author, preview and one button: Restore this version.

  • Restore as a new version, so the restore itself can be undone.

  • Thin old snapshots to one per day after a few days. Full copies of text are usually simpler than diffs.

How do bulk actions and imports fit in?

Bulk actions do the real damage, because one click touches many rows. Store a batch ID on every affected row so one Undo restores exactly that batch. Imports work the same way: tag rows with the import ID and "undo this import" is one query.

A spiral notebook with the word MISTAKE handwritten on it next to a red eraser printed with DELETE, on a black background

When does deleted data actually get deleted?

A trash folder promises two things: items come back during the window, and they really go away after it. The second half needs a purge job:

  • Run it daily: hard delete rows whose deleted_at is older than the window, children and stored files included.

  • Delete in small batches so it does not lock busy tables.

  • Log rows removed and alert if it stops, or your trash quietly becomes "keep forever".

Laravel ships a version of this: Prunable models plus a scheduled model:prune command, and its docs note that soft-deleted models matching the prunable query are force deleted.

Privacy requests interact with all of this. Under the GDPR, people can ask for their personal data to be erased, and the European Commission's guidance on dealing with requests from individuals says companies must reply without undue delay and in principle within one month. A soft-deleted row is still stored data, so an erasure request or an account deletion should hard delete that person's data, including what sits in the trash, rather than wait for the normal window. State your trash window in your privacy policy, make sure account deletion really removes data, and get proper advice for your situation.

Aren't backups enough?

No. Backups protect you from losing the database. They are a terrible way to return one deleted document: restore a snapshot somewhere, dig out a row, copy it across and hope. Keep backups for disasters and test them; this DEV post on running a monthly backup restore drill shows how. Undo, trash and versions are for everyday mistakes.

How do you know it is working?

Track a few numbers before and after you ship:

  • Lost-work support tickets. Tag every "I deleted X by mistake" message for a month before and after.

  • Undo clicks and trash restores per week. Each is a ticket nobody had to write; a high count is good news.

  • Time from delete to restore. Seconds means the toast works; days means the trash window matters.

  • Purge job runs.

A made-up example: Cuebarrow adds a trash folder

Cuebarrow is invented for this example, and so are its founder, its users and every number below. It is a shot-list and call-sheet planner for small video crews, run by a solo founder called Oriel.

Oriel kept getting the same email: a producer deleted the wrong shot list the night before a shoot, straight through an "Are you sure?" dialog. Each recovery meant restoring a backup and copying rows by hand.

Over one week Oriel added deleted_at to shot lists, shots and call sheets, moved the filter into one shared query helper and made the list-name index partial. Routine deletes lost their dialogs and gained an Undo message naming the list. A Trash page showed days left, Restore and Delete forever. Deleting a whole production kept a dialog that asks for its name. A nightly job purged anything older than 30 days and posted the count to Oriel's ops channel.

Two surprises: the weekly crew email still listed deleted shots because it used its own raw query, and a producer restored "Day 2" after creating a new "Day 2", so restore now adds "(restored)". A month later those emails had stopped.

What does weak vs better look like?

Situation

Weak

Better

Deleting one task

"Are you sure?" with Yes / No

Delete now, show "Deleted 'Fix login copy'. Undo"

Deleting a whole workspace

Same quick Yes / No as a task

Specific dialog naming what is lost, type the workspace name to confirm

Undo message

"Item deleted" that vanishes in a blink

Names the item, stays while focused, says it is in Trash for 30 days

Data model

Real DELETE, restore from backups

deleted_at column, filter in one shared place

Unique names

Plain unique index blocks reusing a deleted name

Partial unique index on live rows; restore renames on clash

Long-form text

Only the latest save exists

Autosave plus snapshots; restore creates a new version

Bulk delete

Undo restores some rows, or none

Batch ID on every row, one Undo restores the batch

Erasure request

Trashed rows wait out the window

Hard delete that person's data, trash included

What traps catch small teams?

  • Hiding only the parent. The project is trashed but its tasks still appear in "My tasks".

  • Files left behind. The row is purged but its uploads stay in storage forever.

  • Usage limits that count trash. Pick one rule for whether trashed items count and apply it everywhere.

  • Undo in one place only. The list view has Undo but the detail page does not. Use one shared delete helper.

  • "Permanently deleted" that is only flagged. If the UI says gone, it must be gone.

A 7-day plan to add undo and a trash folder

A planning guess for one maker working part-time on an existing product. Adjust to your stack.

Day

Work

Done when

1

List every destructive action in the product and the last month's "I lost X" tickets

A sheet of actions, each marked undo, trash, version or confirm

2

Add deleted_at to the top two tables; move the filter into one shared place

Deleted rows vanish from every list, count, search and export you tested

3

Partial unique indexes; child records hidden with parents; restore name clash rule

Delete, recreate and restore an item with the same name without errors

4

Undo message: specific text, focusable button, status role; remove routine dialogs

Delete and undo work with keyboard only

5

Trash page with days left, Restore, Delete forever, Empty trash

A teammate restores an item without help

6

Daily purge job for rows and files; erasure and account deletion hard delete trash too

A test item older than the window is gone from the database and storage

7

Version snapshots for your main long-form field; bulk action batch IDs; start the metrics

Restore an older version, then undo that restore

Undo and trash FAQ

Is soft delete a bad idea?

No, but it has costs: every query must filter deleted rows, unique constraints need care, and you need a purge job. With the filter in one place and a scheduled purge, it is the simplest way to give users a trash folder.

How long should deleted items stay in the trash?

Long enough for people to notice a mistake, short enough that you are not hoarding data. Thirty days is a familiar default because Google Drive uses it. Show the days left on each item.

Should I use a confirmation dialog or undo?

Undo for everyday actions on your own data. A confirmation dialog for actions you cannot reverse, such as sending email, charging money or deleting a whole workspace, written specifically enough that people actually read it.

Does soft delete break GDPR deletion?

It can if you treat the trash as deleted. A soft-deleted row is still stored data, so erasure requests and account deletions should hard delete it. Check the rules that apply to you with a qualified adviser.

Make mistakes cheap

Users will always click the wrong thing. The products people trust are the ones where that costs them a second, not an evening. And once your product forgives mistakes like this, MakerHunt is a friendly place to show it to other makers.

soft deleteundo button UXtrash folderrecycle binundo vs confirmation dialogversion historydeleted_atUX designmakersGrowth