Skip to content

Step 8: Add Empty State

Time: ~5 minutes | Type: UX | Concepts: Conditional Rendering, Empty States, User Experience

What We're Building

A friendly message and call-to-action when the user has no todos yet, instead of showing a blank screen.

Before You Code: Understanding Empty States

💡 Ask AI First:

What is an empty state in UX design?
Why not just show a blank screen when there's no data?
What should a good empty state include?
How do I conditionally render different UI in React?
When should I show the empty state vs the loading state?

What you should learn:

  • Empty states guide users on what to do next
  • Blank screens are confusing (broken? no access?)
  • Good empty states have: message + action (button/link)
  • Use conditional rendering: if (condition) return <A />; return <B />;
  • Show loading while fetching, empty state if no results

Let's Build It

Prompt: Add Empty State to TodosList

Update the TodosList component to show an empty state when there are no todos.

Requirements:
1. After loading completes, check if todos array is empty
2. If empty, render:
   - Friendly message like "No tasks yet!" or "Your todo list is empty"
   - A link to /todos/new with text like "Add your first task"
3. If not empty, render the list as normal
4. Make sure the empty state only shows AFTER loading (not during)

After updating the code, explain:
- The difference between loading state and empty state
- Why we check todos.length === 0
- How conditional rendering works in React

What to expect:

  • Conditional check: if (todos.length === 0) return <EmptyState />
  • Friendly message
  • Link to add new todo
  • Only shown after loading completes

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. What's the order of UI states (loading → empty → list)?
2. Why do we check loading first, then empty, then show list?
3. What would happen if we checked empty before loading?
4. Why include a link in the empty state?
5. How does the user flow work when they click "Add your first task"?

Key concepts to understand:

  • Three states: loading → empty (if no data) → list (if data)
  • Check loading first (during fetch), then empty, then list
  • If we checked empty during loading, we'd briefly show "No tasks!" before they load
  • Link guides user to take action
  • User clicks → goes to form → adds todo → redirects back → sees their new todo

Verify It Works

Manual Testing:

  1. Test with existing todos:

    • Go to /todos
    • If you have todos: should see the list (not empty state)
    • Empty state should NOT appear
  2. Test with no todos:

    • Option A: Delete all your todos from Firebase Console:
      • Firebase Console → Firestore → Data → todos
      • Click each document → Delete
    • Option B: Create a new user account and log in
    • Go to /todos
    • Should see empty state:
      • Message like "No tasks yet!"
      • Link "Add your first task"
    • Should NOT see loading indefinitely
    • Should NOT see empty list (<ul></ul>)
  3. Test the flow:

    • From empty state, click "Add your first task" link
    • Should navigate to /todos/new
    • Add a todo
    • After redirect to /todos
    • Should now see the list (not empty state)
  4. Test loading → empty transition:

    • Slow down your network (DevTools → Network → Throttling → Slow 3G)
    • Refresh /todos with no todos
    • Should see "Loading..." first
    • After loading completes, should see empty state
    • Should NOT show empty state while loading

Checklist:

  • [ ] Empty state appears when todos array is empty
  • [ ] Empty state shows AFTER loading (not during)
  • [ ] Empty state has friendly message
  • [ ] Empty state has link to add new todo
  • [ ] Link works (navigates to /todos/new)
  • [ ] After adding first todo, list appears (no more empty state)
  • [ ] With existing todos, empty state doesn't appear

Common Issues

Empty state shows even with todos

Problem: Logic error in conditional rendering.

Fix: Check order:

typescript
if (loading) {
  return <p>Loading...</p>;
}

if (todos.length === 0) {  // Check after loading
  return <EmptyState />;
}

return <List />;  // Only if not loading and has todos

Empty state appears briefly during loading

Problem: Checking empty before loading completes.

Fix: Make sure loading check comes first:

typescript
// Right order
if (loading) return <Loading />;
if (todos.length === 0) return <Empty />;
return <List />;

// Wrong order (shows empty before data loads)
if (todos.length === 0) return <Empty />;
if (loading) return <Loading />;
return <List />;

Problem: Using <a> tag instead of <Link>.

Fix:

typescript
import { Link } from 'react-router-dom';

// Right
<Link to="/todos/new">Add your first task</Link>

// Wrong (causes page reload)
<a href="/todos/new">Add your first task</a>

Empty state appears even after adding todo

Problem: State not updating after redirect.

Fix: Make sure AddTodoForm redirects to /todos and TodosList fetches on mount. The useEffect should re-run when you navigate back.

If still broken, add a key or force re-fetch:

typescript
// In TodosPage.tsx
<TodosList key={Date.now()} />  // Forces re-mount on navigation

Better solution: Use a refresh mechanism or real-time updates (advanced).

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 } 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 { 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]);

  // Loading state
  if (loading) {
    return <p>Loading your tasks...</p>;
  }

  // Empty state (after loading, if no todos)
  if (todos.length === 0) {
    return (
      <div>
        <p>No tasks yet!</p>
        <Link to="/todos/new">Add your first task</Link>
      </div>
    );
  }

  // List state (has todos)
  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>
        </li>
      ))}
    </ul>
  );
}

What You Learned

At this point you should understand:

  • ✅ What empty states are and why they matter
  • ✅ How to conditionally render different UI based on state
  • ✅ The correct order: loading → empty → list
  • ✅ Why we check loading before checking empty
  • ✅ How to guide users with calls-to-action
  • ✅ The importance of good UX for first-time users

Next Step

Empty state is done! Now let's build the UPDATE operation — editing existing todos:

Step 9: Edit Item →

Built for learning | Open source on GitHub