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 ReactWhat 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:
Test with existing todos:
- Go to
/todos - If you have todos: should see the list (not empty state)
- Empty state should NOT appear
- Go to
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>)
- Option A: Delete all your todos from Firebase Console:
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)
Test loading → empty transition:
- Slow down your network (DevTools → Network → Throttling → Slow 3G)
- Refresh
/todoswith 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:
if (loading) {
return <p>Loading...</p>;
}
if (todos.length === 0) { // Check after loading
return <EmptyState />;
}
return <List />; // Only if not loading and has todosEmpty state appears briefly during loading
Problem: Checking empty before loading completes.
Fix: Make sure loading check comes first:
// 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 />;Link in empty state doesn't work
Problem: Using <a> tag instead of <Link>.
Fix:
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:
// In TodosPage.tsx
<TodosList key={Date.now()} /> // Forces re-mount on navigationBetter solution: Use a refresh mechanism or real-time updates (advanced).
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 } 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: