With 800 ms of latency, a heart that fills only after the server replies feels broken. An optimistic update changes the cache at once, assuming success, and undoes the change if the server refuses:
onMutate: async () => { // runs before the request is sent
await qc.cancelQueries({ queryKey: keys.book(id) });
const before = qc.getQueryData<ApiBook>(keys.book(id));
qc.setQueryData<ApiBook>(keys.book(id), (b) => b && { ...b, favorite: !b.favorite });
console.log('optimistic: favorite =', !before?.favorite);
return { before }; // becomes `context` below
},
onError: (err, _vars, context) => {
console.log('rolled back:', err.message);
qc.setQueryData(keys.book(id), context?.before);
},cancelQueries stops a running refetch that would overwrite the optimistic value. The practice server's POST /fail-next makes the next favorite request answer 503. Tapping the heart twice, arming that hook between:
'optimistic: favorite =', true 'book 3: data, fetch idle,', 'updated 16:16:19' ... 'optimistic: favorite =', false 'book 3: data, fetch idle,', 'updated 16:16:28' 'rolled back:', 'POST /books/3/favorite failed: 503' 'book 3: data, fetch fetching,', 'updated 16:16:29' 'book 3: data, fetch idle,', 'updated 16:16:30'
Each tap re-rendered the screen immediately with the new heart; the request followed. The second one failed, so onError put back the saved copy and onSettled refetched to be sure. Use optimistic updates for small, easily reversed changes (favorites, toggles, reordering), and tell the user when you roll back, for example with a toast (ToastAndroid). For payments, wait for the server and show a spinner.