I once caught myself tapping "OK" before I had even read the words, while removing a saved wallpaper from a list in my own app. The "Are you sure you want to delete this?" dialog was meant to be a safety net, but because it appeared every single time, I had trained myself to dismiss it on reflex. A safety net that nobody reads is not really protecting anyone.
For actions that happen often and can be reversed — like removing one item from a list — an undoable delete fits better than a confirmation dialog: remove it now, and let the person take it back for a few seconds. As an indie developer who has run wallpaper and calming-themed apps for years, I saw delete-related questions drop noticeably once I leaned on this pattern instead of confirmations. This article walks through adding an undoable delete to an app built with Rork (Expo), and where you should still keep a confirmation dialog.
Where confirmations fit, and where they don't
Let's draw the line clearly. Two questions decide it: can the action be reversed, and how often does it happen?
| Example action | Frequency | Reversible? | Better fit |
|---|---|---|---|
| Remove one favorite | High | Yes | Undoable delete |
| Delete one note / history item | High | Yes | Undoable delete |
| Delete account | Very rare | No | Confirmation (+ retype) |
| Confirm a payment / send | Rare | No | Confirmation |
| Reset all data | Very rare | No | Confirmation (+ type to confirm) |
The more you put a confirmation in front of a frequent, reversible action, the more you teach people to "just tap OK." Save the pauses for the actions that truly cannot be taken back. Reducing confirmations is also a way to raise the weight of the ones you keep.
How an undoable delete works
The key is to separate when the item disappears from the screen from when the data is actually deleted.
- The person deletes an item, and it is hidden from the list immediately (it looks gone).
- At the same time, a toast with an "Undo" button appears for a few seconds.
- If "Undo" is tapped within the window, the hidden item comes back.
- If it isn't, only then is the item really removed from storage.
In other words, a delete first goes into a "pending delete" buffer and is committed when the window expires. From the user's point of view, it vanishes instantly and there is still a grace period — you get both.
Implementing it in Expo
Let's build a custom hook that manages pending deletes. It runs on plain react-native with no extra libraries.
// useUndoableDelete.ts
import { useCallback, useEffect, useRef, useState } from "react";
type PendingMap = Record<string, ReturnType<typeof setTimeout>>;
export function useUndoableDelete<T extends { id: string }>(
items: T[],
commitDelete: (id: string) => Promise<void> | void,
windowMs = 5000
) {
// Pending IDs: hidden from the screen, but not deleted yet
const [pendingIds, setPendingIds] = useState<Set<string>>(new Set());
const timers = useRef<PendingMap>({});
// Actually commit the deletion
const commit = useCallback(
(id: string) => {
delete timers.current[id];
setPendingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
commitDelete(id);
},
[commitDelete]
);
// Schedule a delete (disappears from the screen immediately)
const remove = useCallback(
(id: string) => {
setPendingIds((prev) => new Set(prev).add(id));
timers.current[id] = setTimeout(() => commit(id), windowMs);
},
[commit, windowMs]
);
// Undo (cancel the timer and restore)
const undo = useCallback((id: string) => {
const t = timers.current[id];
if (t) clearTimeout(t);
delete timers.current[id];
setPendingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}, []);
// Render only the items that are NOT pending
const visibleItems = items.filter((it) => !pendingIds.has(it.id));
// On unmount, commit everything pending before leaving
useEffect(() => {
return () => {
Object.keys(timers.current).forEach((id) => {
clearTimeout(timers.current[id]);
commitDelete(id);
});
};
}, [commitDelete]);
return { visibleItems, remove, undo, pendingIds };
}The important part is that visibleItems is narrowed to "everything that is not pending." All the state lives in one place (pendingIds), so the list and the toast can never disagree.
The calling side looks like this:
// FavoritesScreen.tsx
import { useState } from "react";
import { FlatList, Text, TouchableOpacity, View } from "react-native";
import { useUndoableDelete } from "./useUndoableDelete";
export function FavoritesScreen({ data, onDeleteFromStorage }) {
const [lastDeleted, setLastDeleted] = useState<string | null>(null);
const { visibleItems, remove, undo } = useUndoableDelete(
data,
onDeleteFromStorage, // do the real delete here (AsyncStorage, etc.)
5000
);
const handleDelete = (id: string) => {
remove(id);
setLastDeleted(id);
};
const handleUndo = () => {
if (lastDeleted) undo(lastDeleted);
setLastDeleted(null);
};
return (
<View style={{ flex: 1 }}>
<FlatList
data={visibleItems}
keyExtractor={(it) => it.id}
renderItem={({ item }) => (
<TouchableOpacity onPress={() => handleDelete(item.id)}>
<Text style={{ padding: 16 }}>{item.title}</Text>
</TouchableOpacity>
)}
/>
{lastDeleted && (
<View
style={{
position: "absolute",
bottom: 24,
left: 16,
right: 16,
flexDirection: "row",
justifyContent: "space-between",
backgroundColor: "#222",
padding: 14,
borderRadius: 12,
}}
>
<Text style={{ color: "#fff" }}>1 item deleted</Text>
<TouchableOpacity onPress={handleUndo}>
<Text style={{ color: "#4da3ff", fontWeight: "600" }}>Undo</Text>
</TouchableOpacity>
</View>
)}
</View>
);
}Keep the toast's auto-dismiss aligned with the windowMs used to commit the delete, and the behavior stays easy to reason about. For brevity I track lastDeleted directly here; in production, give the toast its own timer of the same length and call setLastDeleted(null) when it expires.
The easy-to-miss problem: the app goes to the background
This is where most people trip. If the person hits the home button right after scheduling a delete, the OS may pause or delay setTimeout. The result is the worst kind of behavior for trust: an item that "should be gone" comes back on the next launch.
The fix is to commit all pending deletes the moment the app leaves the foreground. Decide that the grace period only exists while the app is on screen.
import { AppState } from "react-native";
// add inside useUndoableDelete
useEffect(() => {
const sub = AppState.addEventListener("change", (state) => {
if (state !== "active") {
// Commit everything pending when going to the background
Object.keys(timers.current).forEach((id) => {
clearTimeout(timers.current[id]);
commit(id);
});
}
});
return () => sub.remove();
}, [commit]);Now the rule is consistent — "you can undo only while you are looking at the app" — and the resurrection bug is gone. Together with the unmount commit above, this closes every path that could leave a delete dangling.
Prompting Rork to build it
If you ask Rork to "add a confirmation dialog to the delete button," you will usually get an Alert.alert confirmation. Instead, describe the behavior. Something like: "When a list item is deleted, remove it from the list right away and show a notification at the bottom with an Undo button for 5 seconds; commit the delete if Undo isn't tapped within 5 seconds, otherwise restore it." Telling it to design around undo rather than confirmation gets you much closer to what you want. Generated code tends to skip the background-commit step from above, so plan to add that one yourself.
A confirmation dialog is not a universal safety net; overused, it loses its effect. Next time you design a delete button, ask yourself once: can this be taken back? If it can, letting people delete and then restore is usually kinder than stopping them with a question.
On a related note, for how to present a list that becomes empty after deletion, see Designing the empty states of a Rork app, and for the broader screen-design toolkit around delete buttons, see UX design patterns for Rork apps.