serialize TanStack Query mutations
I was building drag and drop reordering with
optimistic updates.
The API takes positions, not ids. A move is from one index to another:
import { useMutation } from "@tanstack/react-query";const moveItem = useMutation({ mutationFn: ({ from, to }) => api.patch("/items/move", { from, to }),});
Each drop calls mutate(). The optimistic update reorders the list right away,
so the UI feels instant. Then a fast user drags twice, and the server moves the
wrong item.
The problem: mutations run in parallel by default. The second drag computes its
from against the optimistic list, but the requests race, and the server may
apply them in a different order:
server list: [A, B, C, D]drag 1: D to the top optimistic: [D, A, B, C] sends { from: 3, to: 0 }drag 2: B to the end optimistic: [D, A, C, B] sends { from: 2, to: 3 }drag 2 arrives first: [A, B, C, D] from: 2 is C, not B [A, B, D, C]then drag 1: from: 3 is C again [C, A, B, D]server: [C, A, B, D]client: [D, A, C, B]
With ids the damage would be a wrong position. With positions the server moves a different item entirely.
Turns out mutation scopes exist. Give mutations the same scope id and they run in serial:
const moveItem = useMutation({ mutationFn: ({ from, to }) => api.patch("/items/move", { from, to }), scope: { id: "list-reorder", },});
While one move is in flight, the next one waits in isPaused: true state and
fires once the previous one settles. The server applies the moves in the order
the user made them, so from always points at the item the user dragged.
The queue costs no perceived latency. onMutate runs immediately, even for a
paused mutation, so the optimistic reorder still happens on drop.
Mutation scopes shipped in v5.31.0.