Real-World CRUD: Building a Todo App
A todo app is the perfect vehicle for learning mutations end-to-end. You'll build CREATE, READ, UPDATE, and DELETE operations, manage cache coherency, add optimistic updates, and handle errors—all patterns that scale to production apps. This article walks through a complete, runnable todo app using React Query, showing every pattern from earlier articles in action.
Setting Up the Todo App
Start with a simple server API contract:
GET /api/todos → fetch all todos
POST /api/todos → create todo, returns { id, title, completed, createdAt }
PUT /api/todos/:id → update todo (partial fields OK)
DELETE /api/todos/:id → delete todo
Create a React app with these custom hooks:
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
// Fetch all todos
function useTodos() {
return useQuery({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos');
if (!res.ok) throw new Error('Fetch todos failed');
return res.json();
},
});
}
// Create a new todo
function useCreateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (title) => {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!res.ok) throw new Error('Create todo failed');
return res.json();
},
onSuccess: (newTodo) => {
// Add optimistically-created todo to cache
queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
// Also invalidate to ensure consistency
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
// Update a todo (mark complete, rename, etc.)
function useUpdateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...updates }) => {
const res = await fetch(`/api/todos/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!res.ok) throw new Error('Update todo failed');
return res.json();
},
onMutate: async (variables) => {
// Cancel pending refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot old todos
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update the todo in the list
queryClient.setQueryData(['todos'], (old) =>
old.map((todo) =>
todo.id === variables.id ? { ...todo, ...variables } : todo
)
);
return { previousTodos };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
},
onSuccess: () => {
// Validate with server
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
// Delete a todo
function useDeleteTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id) => {
const res = await fetch(`/api/todos/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Delete todo failed');
return res.json();
},
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically remove from list
queryClient.setQueryData(['todos'], (old) => old.filter((t) => t.id !== id));
return { previousTodos };
},
onError: (err, id, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
Building the UI Components
Compose the hooks into a complete todo app:
export default function TodoApp() {
const { data: todos = [], isLoading, error } = useTodos();
const createMutation = useCreateTodo();
const updateMutation = useUpdateTodo();
const deleteMutation = useDeleteTodo();
const [newTitle, setNewTitle] = React.useState('');
const handleAddTodo = (e) => {
e.preventDefault();
if (!newTitle.trim()) return;
createMutation.mutate(newTitle, {
onSuccess: () => {
setNewTitle('');
},
});
};
const handleToggle = (todo) => {
updateMutation.mutate({ id: todo.id, completed: !todo.completed });
};
const handleDelete = (id) => {
if (confirm('Delete this todo?')) {
deleteMutation.mutate(id);
}
};
const handleRename = (id, newTitle) => {
updateMutation.mutate({ id, title: newTitle });
};
if (isLoading) return <p>Loading todos...</p>;
if (error) return <p style={{ color: 'red' }}>Error: {error.message}</p>;
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '2rem' }}>
<h1>My Todos</h1>
{/* Add form */}
<form onSubmit={handleAddTodo} style={{ marginBottom: '2rem' }}>
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="What needs to be done?"
disabled={createMutation.isPending}
style={{ width: '100%', padding: '0.5rem', marginBottom: '0.5rem' }}
/>
<button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Adding...' : 'Add Todo'}
</button>
{createMutation.isError && (
<p style={{ color: 'red' }}>Failed to add: {createMutation.error.message}</p>
)}
</form>
{/* Todos list */}
<ul style={{ listStyle: 'none', padding: 0 }}>
{todos.map((todo) => (
<li
key={todo.id}
style={{
padding: '1rem',
borderBottom: '1px solid #ddd',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
opacity: todo.id.toString().startsWith('temp-') ? 0.6 : 1,
}}
>
<div style={{ flex: 1 }}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo)}
disabled={updateMutation.isPending}
style={{ marginRight: '0.5rem' }}
/>
<span
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
cursor: 'pointer',
}}
onDoubleClick={() => {
const newTitle = prompt('Rename todo:', todo.title);
if (newTitle) handleRename(todo.id, newTitle);
}}
>
{todo.title}
</span>
</div>
<button
onClick={() => handleDelete(todo.id)}
disabled={deleteMutation.isPending}
style={{
marginLeft: '0.5rem',
padding: '0.25rem 0.75rem',
backgroundColor: '#ff4444',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Delete
</button>
</li>
))}
</ul>
{todos.length === 0 && <p>No todos yet. Add one above!</p>}
{/* Stats */}
<div style={{ marginTop: '2rem', paddingTop: '1rem', borderTop: '1px solid #ddd' }}>
<p>
Total: {todos.length} | Completed: {todos.filter((t) => t.completed).length}
</p>
</div>
</div>
);
}
Adding Filters and Sorting
Extend the app with filtered views:
function useTodos(filter = 'all') {
return useQuery({
queryKey: ['todos', filter],
queryFn: async () => {
const res = await fetch(`/api/todos?filter=${filter}`);
if (!res.ok) throw new Error('Fetch failed');
return res.json();
},
});
}
export default function TodoAppWithFilters() {
const [filter, setFilter] = React.useState('all'); // 'all', 'completed', 'active'
const { data: todos = [], isLoading, error } = useTodos(filter);
const createMutation = useCreateTodo();
const updateMutation = useUpdateTodo();
const deleteMutation = useDeleteTodo();
const [newTitle, setNewTitle] = React.useState('');
const handleAddTodo = (e) => {
e.preventDefault();
if (!newTitle.trim()) return;
createMutation.mutate(newTitle, {
onSuccess: () => {
setNewTitle('');
setFilter('all'); // Show all after creating
},
});
};
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '2rem' }}>
<h1>My Todos</h1>
<form onSubmit={handleAddTodo} style={{ marginBottom: '2rem' }}>
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="What needs to be done?"
disabled={createMutation.isPending}
style={{ width: '100%', padding: '0.5rem', marginBottom: '0.5rem' }}
/>
<button type="submit" disabled={createMutation.isPending}>
Add Todo
</button>
</form>
{/* Filter tabs */}
<div style={{ marginBottom: '1rem' }}>
{['all', 'active', 'completed'].map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
style={{
marginRight: '0.5rem',
padding: '0.5rem 1rem',
backgroundColor: filter === f ? '#007bff' : '#ddd',
color: filter === f ? 'white' : 'black',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{isLoading ? (
<p>Loading...</p>
) : error ? (
<p style={{ color: 'red' }}>Error: {error.message}</p>
) : (
<>
<ul style={{ listStyle: 'none', padding: 0 }}>
{todos.map((todo) => (
<li key={todo.id} style={{ padding: '1rem', borderBottom: '1px solid #ddd' }}>
{/* Todo item UI */}
</li>
))}
</ul>
{todos.length === 0 && <p>No todos in this category.</p>}
</>
)}
</div>
);
}
Key Takeaways
- Separate data fetching (queries) from mutations; use distinct hooks for each CRUD operation.
- Combine optimistic updates in
onMutatewithinvalidateQueriesinonSuccessfor instant feedback and eventual consistency. - For each mutation, save a snapshot of prior state in
onMutateand restore it inonErrorfor seamless rollback. - Use
disabledstates on buttons and inputs to prevent double-submission and signal to users that work is in progress. - Group related mutations (create, update, delete) in a single component or pass them as props to reduce prop drilling.
- Validate user input before sending to the server to catch errors early.
Frequently Asked Questions
How do I add bulk operations (delete all completed)?
Create a new mutation that sends a batch request to the server: DELETE /api/todos?filter=completed. Or iterate and call deleteMutation for each todo (slower but simpler).
Should I use optimistic updates for delete?
Yes, but with caution. Deleting is destructive. If the request fails, rolling back is safe (the todo reappears). Consider showing a confirmation dialog before deleting.
How do I persist todos across page reloads?
Use localStorage as a fallback cache. After successful mutations, store the todos in localStorage. On app load, use localStorage data while fetching fresh data from the server.
Can I have multiple todo lists (projects)?
Yes. Change the query key to ['todos', projectId] and filter mutations to that project. Invalidate the correct query key in each mutation's onSuccess.
How do I undo a delete?
Keep the deleted todo in cache (mark as deleted) for a few seconds and show an "Undo" button. If the user clicks it, restore the todo from cache without hitting the server.