Skip to content

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 delete

What 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:

  1. Make sure you have todos:

    • Go to /todos
    • Should see list with todos
    • Each should have a "Delete" button
  2. 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)
  3. Test successful delete:

    • Click "Delete" again
    • Click "OK" or "Yes" in confirmation
    • Todo should disappear from list
    • No errors in console
  4. Verify in Firestore:

    • Firebase Console → Firestore → todos
    • Deleted todo should be gone
    • Other todos still present
  5. Test multiple deletes:

    • Delete another todo
    • Confirm
    • Should disappear
    • List should update correctly
  6. Test with last todo:

    • Delete all todos
    • After last delete:
      • List should be empty
      • Should see empty state: "No tasks yet!"
  7. 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:

typescript
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

typescript
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

typescript
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:

typescript
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:

typescript
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:

javascript
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:

typescript
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:

typescript
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:

Step 11: Verification & Commit →

Built for learning | Open source on GitHub