Wire up full CRUD for tasks using TanStack Query mutations with optimistic updates. Every write immediately reflects in the UI before the server responds — and gracefully rolls back if the request fails.
useMutationlifecycle:onMutate→onError→onSettledqueryClient.cancelQueriesbefore mutating to prevent race conditions- Snapshot-and-restore rollback pattern with
context queryClient.setQueryDatafor instant optimistic cache updatesqueryClient.invalidateQuerieson settled to sync with server- Pessimistic vs optimistic update trade-offs
- Undo-delete pattern with a 3-second grace window
The start/ directory has all reads wired to TanStack Query (from Challenge 15). The MSW handlers for POST /api/projects/:id/tasks, PATCH /api/tasks/:id, and DELETE /api/tasks/:id already exist — but the UI components still use useReducer for local state. Your job is to replace those local mutations with proper TanStack Query mutations.
Add typed fetch functions for each mutation:
createTask(projectId: string, data: Partial<Task>): Promise<Task>
updateTask(taskId: string, data: Partial<Task>): Promise<Task>
deleteTask(taskId: string): Promise<void>export function useCreateTask(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<Task>) => createTask(projectId, data),
onMutate: async (newTaskData) => {
// 1. Cancel any in-flight queries for this project
await queryClient.cancelQueries({ queryKey: ['projects', projectId] });
// 2. Snapshot the current cache
const previous = queryClient.getQueryData(['projects', projectId]);
// 3. Optimistically add the new task
queryClient.setQueryData(['projects', projectId], (old) => ...);
return { previous };
},
onError: (_err, _vars, context) => {
// Roll back to snapshot
if (context?.previous) queryClient.setQueryData(...);
},
onSettled: () => {
// Always refetch to sync with server
queryClient.invalidateQueries({ queryKey: ['projects', projectId] });
},
});
}Same onMutate/onError/onSettled pattern for status changes and assignee updates.
Optimistically remove the task from the cache. Show a 3-second toast with an Undo button. If the user clicks Undo within the window, call queryClient.cancelMutate (or simply re-add with a follow-up optimistic update). If Undo is not clicked, let the mutation proceed.
Remove useReducer. Read tasks directly from the useProject cache (via outlet context). Pass mutation hooks down as callbacks.
Replace dispatch prop with mutation callback props.
Call useUpdateTask mutation instead of dispatching a reducer action.
Call useUpdateTask mutation instead of dispatching a reducer action.
Call useCreateTask mutation instead of dispatching ADD_TASK.
Support variant: 'success' | 'error' and an optional action button (for Undo).
User clicks "Mark In Progress"
│
├─ onMutate: cancel queries → snapshot → update cache instantly
│ UI shows "In Progress" badge immediately
│
├─ [network request fires in background]
│
├─ onError: restore snapshot → show error toast
│ UI reverts to "Todo"
│
└─ onSettled: invalidate query → background refetch confirms server state
If a background refetch is already in-flight when you call setQueryData, the refetch could complete and overwrite your optimistic update with stale data. cancelQueries prevents this race condition.
onSettled runs after both success and error. Invalidating here ensures the cache is always synced with the server after the mutation completes — even if the mutation succeeded but returned slightly different data than what you predicted optimistically.
- Creating a task instantly shows it in the list (optimistic), then syncs with the server
- Updating a task status instantly shows the new badge, rolls back on error
- Assigning a task instantly shows the assignee name, rolls back on error
- Deleting a task shows a 3-second undo toast; clicking Undo restores the task
- On any mutation error, the UI rolls back and shows an error toast
-
useReducer/taskReducer.tsis fully removed from the task flow - TypeScript strict — no
any