acme/web · Pull Request #247
Add optimistic updates to task list mutations
What this PR does
- Replaces the await-then-refetch pattern in
TaskListwith optimistic cache writes, so toggling or reordering a task feels instant instead of waiting ~300ms for the round-trip. - Introduces a small
useOptimisticTaskshook that wraps the mutation, snapshots the previous list, and rolls back on error. - Extends the API client to accept an idempotency key per mutation and adds a toast when a rollback fires.
Risk map
safe
worth a look
needs attention
Files
src/hooks/useOptimisticTasks.ts
needs attention
+58 −0
@@ -0,0 +1,58 @@
1+import { useMutation, useQueryClient } from '@tanstack/react-query';
2+import { updateTask, TaskPatch } from '../api/tasks';
3+import type { Task } from '../types/task';
4+
5+export function useOptimisticTasks(boardId: string) {
6+ const qc = useQueryClient();
7+ const key = ['tasks', boardId];
8+
9+ return useMutation({
10+ mutationFn: (patch: TaskPatch) => updateTask(patch),
11+ onMutate: async (patch) => {
12+ const prev = qc.getQueryData<Task[]>(key);
13+ qc.setQueryData<Task[]>(key, (old = []) =>
14+ old.map(t => t.id === patch.id ? { ...t, ...patch } : t)
15+ );
16+ return { prev };
17+ },
18+ onError: (_e, _p, ctx) => qc.setQueryData(key, ctx?.prev),
19+ });
20+}
src/components/TaskList.tsx
worth a look
+31 −24
@@ -42,14 +42,17 @@ export function TaskList({ boardId }: Props) {
42 const { data: tasks } = useTasks(boardId);
43- const [pending, setPending] = useState<string | null>(null);
44-
45- async function toggle(task: Task) {
46- setPending(task.id);
47- await updateTask({ id: task.id, done: !task.done });
48- await refetch();
49- setPending(null);
50- }
43+ const { mutate, isPending } = useOptimisticTasks(boardId);
44+
45+ const toggle = (task: Task) =>
46+ mutate({ id: task.id, done: !task.done });
47
48 return (
49 <ul className="tasks">
50- {tasks?.map(t => <TaskRow key={t.id} task={t} busy={pending === t.id} />)}
50+ {tasks?.map(t => <TaskRow key={t.id} task={t} onToggle={toggle} />)}
51 </ul>
line 43
NitisPending is destructured but never read. Either drop it or pass it to TaskRow so the checkbox can dim while the request is in flight.
src/api/tasks.ts
worth a look
+19 −6
@@ -12,10 +12,15 @@ export type TaskPatch = Partial<Task> & { id: string };
12
13-export async function updateTask(patch: TaskPatch) {
14- return http.patch(`/tasks/${patch.id}`, patch);
13+export async function updateTask(
14+ patch: TaskPatch,
15+ key = crypto.randomUUID(),
16+) {
17+ return http.patch(`/tasks/${patch.id}`, patch, {
18+ headers: { 'Idempotency-Key': key },
19+ });
20 }
line 15
BlockingGenerating the idempotency key as a default parameter means retries from the mutation layer get a new key each time, which defeats the purpose. The key should be minted once in onMutate and threaded through.
src/components/Toast.tsx safe +14 −2
Adds a
variant="warning" style and exports pushToast. Purely additive, no behaviour change for existing call sites.src/types/task.ts safe +6 −2
Widens
Task.status to include "archived" and adds an optional updatedAt timestamp. Type-only change.src/components/__tests__/TaskList.test.tsx safe +14 −4
Adds a test asserting the row updates synchronously after click, and one asserting rollback when the mocked request rejects. Both pass locally.
Blocking
onMutatedoesn't callqc.cancelQueries(key)first. If a background refetch lands between the optimistic write and the server response, it will clobber the optimistic state and the UI will flicker back to the old value.NitRollback restores the list but never surfaces the error. Consider wiring the existing
pushToasthere so users know the toggle didn't stick.