import { useMutation, useQueryClient } from '@tanstack/react-query';
type Snip = { id: string; liked: boolean; likesCount: number };
export function useToggleLike(snipId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => {
const res = await fetch(`/api/snips/${snipId}/like`, { method: 'POST' });
if (!res.ok) throw new Error('Like failed');
return (await res.json()) as Snip;
},
onMutate: async () => {
await qc.cancelQueries({ queryKey: ['snip', snipId] });
const prev = qc.getQueryData<Snip>(['snip', snipId]);
if (prev) {
qc.setQueryData<Snip>(['snip', snipId], {
...prev,
liked: !prev.liked,
likesCount: prev.likesCount + (prev.liked ? -1 : 1)
});
}
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(['snip', snipId], ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ['snip', snipId] });
}
});
}
Interactive UI should feel instant even when the network isn’t. With optimistic updates, I update the cache immediately and roll back if the server rejects the mutation. The trick is to keep optimistic state small and obvious: update a count and a boolean, not a whole nested object graph. I also cancel any in-flight refetch for the query I’m about to modify; otherwise a slow refetch can overwrite the optimistic state and cause flicker. After the mutation settles, I invalidate the query to reconcile with the server. Done this way, you get a snappy UX while still converging on the backend as the source of truth.