Step 10: Delete Item
Time: ~10 minutes | Type: Coding | Concepts: Delete Operation, deleteDoc, Confirmation Dialogs, State Management
What We're Building
A delete button with confirmation that removes todos from Firestore and updates the UI.
Before You Code: Understanding Delete
💡 Ask AI First:
How do I delete a Firestore document? Should I always ask for confirmation before deleting? What's the UX best practice for destructive actions? After deleting, should I redirect or update the list in place? What happens if I try to delete a document that doesn't exist? Can I undo a delete in Firestore? How do I refresh the list after deleting an item?
What you should learn:
deleteDoc(docRef)removes a document permanently- Always confirm destructive actions (prevent accidents)
- Use browser
confirm()or custom modal - Update UI after delete (refetch or remove from state)
- Deleting non-existent doc doesn't error (idempotent)
- Firestore has no built-in undo (gone is gone)
- Either refetch the list or filter deleted item from state
Let's Build It
Prompt: Add Delete Functionality to TodosList
Update the TodosList component to add delete functionality.
Requirements:
1. Add a "Delete" button next to each todo
2. On click:
- Show confirmation dialog: "Are you sure you want to delete this task?"
- If user confirms:
- Call deleteDoc() to remove the document
- Refetch the todos list (or update state to remove the deleted item)
- Show success briefly (optional)
- If user cancels: do nothing
3. Handle errors (show error message if delete fails)
4. Optional: disable the delete button while deleting (prevent double-click)
After updating the code, explain:
- Why we need confirmation before deleting
- How deleteDoc() works
- Two ways to update UI after delete: refetch vs filter state
- Why we should handle errors even for deleteWhat to expect:
- Delete button in todo list
- Confirmation dialog (browser confirm or custom)
- deleteDoc() call
- State update (refetch or filter)
- Error handling
Files you'll modify:
src/components/Todos/TodosList.tsx
Understanding What You Built
After AI updates the code, make sure you understand it:
💡 Ask AI to Explain:
In the updated TodosList: 1. Walk me through the delete flow: click → confirm → delete → update UI 2. What does window.confirm() do? 3. If I wanted a custom modal instead of browser confirm, what would I change? 4. What's the difference between refetching vs filtering state after delete? 5. Why might deleteDoc() fail and what errors could occur? 6. What happens if the user clicks delete on an already-deleted document? 7. Should I show a loading indicator during delete?
Key concepts to understand:
- Flow: click → confirm → if yes: deleteDoc → update state
window.confirm()shows browser dialog (returns true/false)- Custom modal: use state + conditional rendering (more control, better UX)
- Refetch: simple but makes extra Firestore read
- Filter state: faster but need to manage state correctly
- Errors: permission denied, network failure
- Deleting non-existent doc succeeds silently
- Loading state prevents double-click issues
Verify It Works
Manual Testing:
Make sure you have todos:
- Go to
/todos - Should see list with todos
- Each should have a "Delete" button
- Go to
Test delete confirmation:
- Click "Delete" on a todo
- Should see confirmation dialog: "Are you sure...?"
- Click "Cancel"
- Dialog closes, todo still in list (not deleted)
Test successful delete:
- Click "Delete" again
- Click "OK" or "Yes" in confirmation
- Todo should disappear from list
- No errors in console
Verify in Firestore:
- Firebase Console → Firestore → todos
- Deleted todo should be gone
- Other todos still present
Test multiple deletes:
- Delete another todo
- Confirm
- Should disappear
- List should update correctly
Test with last todo:
- Delete all todos
- After last delete:
- List should be empty
- Should see empty state: "No tasks yet!"
Test delete failure (optional):
- Temporarily change Firestore rules to deny delete
- Try to delete
- Should see error message
- Todo should remain in list
Checklist:
- [ ] Delete button appears next to each todo
- [ ] Clicking delete shows confirmation
- [ ] Canceling confirmation keeps the todo
- [ ] Confirming removes the todo from list
- [ ] Todo deleted from Firestore
- [ ] List updates immediately after delete
- [ ] No errors in console
- [ ] Deleting last todo shows empty state
- [ ] Can delete multiple todos in succession
Common Issues
Confirmation dialog doesn't appear
Problem: Missing window.confirm() or return early if canceled.
Fix:
const handleDelete = async (todoId: string) => {
const confirmed = window.confirm('Are you sure you want to delete this task?');
if (!confirmed) {
return; // Exit early if user cancels
}
// Delete code here...
};Todo deleted from Firestore but still shows in list
Problem: Not updating state after delete.
Fix Option 1: Refetch
const handleDelete = async (todoId: string) => {
if (!window.confirm('Delete this task?')) return;
await deleteDoc(doc(db, 'todos', todoId));
// Refetch todos
const q = query(
collection(db, 'todos'),
where('userId', '==', currentUser!.uid),
orderBy('createdAt', 'desc')
);
const snapshot = await getDocs(q);
const data = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
setTodos(data);
};Fix Option 2: Filter state
const handleDelete = async (todoId: string) => {
if (!window.confirm('Delete this task?')) return;
await deleteDoc(doc(db, 'todos', todoId));
// Remove from state
setTodos(prevTodos => prevTodos.filter(todo => todo.id !== todoId));
};Can click delete multiple times
Problem: No loading state or button disable.
Fix:
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleDelete = async (todoId: string) => {
if (!window.confirm('Delete?')) return;
setDeletingId(todoId);
try {
await deleteDoc(doc(db, 'todos', todoId));
setTodos(prev => prev.filter(t => t.id !== todoId));
} catch (error) {
console.error('Delete failed:', error);
} finally {
setDeletingId(null);
}
};
// In JSX
<button
onClick={() => handleDelete(todo.id)}
disabled={deletingId === todo.id}
>
{deletingId === todo.id ? 'Deleting...' : 'Delete'}
</button>Delete throws error but todo still disappears from list
Problem: Updating state before confirming delete succeeded.
Fix:
const handleDelete = async (todoId: string) => {
if (!window.confirm('Delete?')) return;
try {
await deleteDoc(doc(db, 'todos', todoId)); // Delete first
setTodos(prev => prev.filter(t => t.id !== todoId)); // Then update state
} catch (error) {
console.error('Delete failed:', error);
alert('Failed to delete. Please try again.');
// State unchanged if error occurs
}
};"Missing or insufficient permissions" error
Problem: Firestore security rules blocking delete.
Fix: Firebase Console → Firestore → Rules:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /todos/{todoId} {
allow read, write: if request.auth != null;
// Or more specific:
// allow delete: if request.auth.uid == resource.data.userId;
}
}
}Empty state doesn't appear after deleting last todo
Problem: Empty state check in wrong place or not re-rendering.
Fix: Make sure empty state check runs after state updates:
if (loading) return <Loading />;
if (todos.length === 0) { // This runs after todos state updates
return <EmptyState />;
}
return <TodoList />;Code Example
Your updated TodosList.tsx should look like this:
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { collection, query, where, orderBy, getDocs, doc, deleteDoc } from 'firebase/firestore';
import { db } from '../../lib/firebase';
import { useAuth } from '../../contexts/AuthContext';
import { Todo } from '../../types/todo';
export default function TodosList() {
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null);
const { currentUser } = useAuth();
useEffect(() => {
const fetchTodos = async () => {
setLoading(true);
try {
const q = query(
collection(db, 'todos'),
where('userId', '==', currentUser!.uid),
orderBy('createdAt', 'desc')
);
const snapshot = await getDocs(q);
const data: Todo[] = snapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
} as Todo));
setTodos(data);
} catch (error) {
console.error('Error fetching todos:', error);
} finally {
setLoading(false);
}
};
fetchTodos();
}, [currentUser]);
const handleDelete = async (todoId: string) => {
if (!window.confirm('Are you sure you want to delete this task?')) {
return;
}
setDeletingId(todoId);
try {
await deleteDoc(doc(db, 'todos', todoId));
setTodos(prevTodos => prevTodos.filter(todo => todo.id !== todoId));
} catch (error) {
console.error('Error deleting todo:', error);
alert('Failed to delete task. Please try again.');
} finally {
setDeletingId(null);
}
};
if (loading) {
return <p>Loading your tasks...</p>;
}
if (todos.length === 0) {
return (
<div>
<p>No tasks yet!</p>
<Link to="/todos/new">Add your first task</Link>
</div>
);
}
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input type="checkbox" checked={todo.completed} readOnly />
<div>
<h3>{todo.title}</h3>
{todo.description && <p>{todo.description}</p>}
<small>{todo.createdAt.toDate().toLocaleDateString()}</small>
</div>
<Link to={`/todos/edit/${todo.id}`}>Edit</Link>
<button
onClick={() => handleDelete(todo.id)}
disabled={deletingId === todo.id}
>
{deletingId === todo.id ? 'Deleting...' : 'Delete'}
</button>
</li>
))}
</ul>
);
}What You Learned
At this point you should understand:
- ✅ How to delete documents with deleteDoc()
- ✅ Why confirmation dialogs are important for destructive actions
- ✅ How to update UI after deletion (refetch or filter state)
- ✅ How to prevent double-clicks with loading states
- ✅ How to handle delete errors gracefully
- ✅ That deleted data is gone permanently (no undo)
Next Step
CRUD is complete! Now let's verify everything works together before committing: