Workbench

Optimistic updates

The four-step recipe, a guard against double taps, and a rule for when an update shouldn't be optimistic.

From: the multi-vendor e-commerce platform (wishlist).

An optimistic update changes the screen before the server answers, and puts things back if the request fails. It's the fastest a UI can feel. It's also code that has to undo itself.

The recipe

Cancel in-flight refetches

A refetch that lands after your patch would undo it.

await queryClient.cancelQueries({ queryKey: idsKey });

Snapshot, then patch

Whatever onMutate returns becomes context in the later callbacks.

const previousIds = queryClient.getQueryData<string[]>(idsKey);

queryClient.setQueryData<string[]>(idsKey, (old = []) =>
  old.filter((id) => id !== productId),
);

return { previousIds };

Roll back in onError

onError: (_error, _vars, context) => {
  if (context?.previousIds) {
    queryClient.setQueryData(idsKey, context.previousIds);
  }
},

Invalidate in onSettled

It runs after success or failure, so the cache always ends up matching the server.

onSettled: () => {
  queryClient.invalidateQueries({ queryKey: idsKey });
},

The full hook

Removing a product from a wishlist patches two caches: the list of saved IDs (which every heart icon reads) and the wishlist page.

use-remove-from-wishlist.ts (simplified)
return useMutation({
  mutationFn: ({ productId }) => removeFromWishlist(productId),

  onMutate: async ({ productId }) => {
    await queryClient.cancelQueries({ queryKey: idsKey });

    const previousIds = queryClient.getQueryData<string[]>(idsKey);
    const previousProducts = queryClient.getQueriesData<Product[]>({
      queryKey: wishlistProductsKey,
    });

    queryClient.setQueryData<string[]>(idsKey, (old = []) =>
      old.filter((id) => id !== productId),
    );
    queryClient.setQueriesData<Product[]>({ queryKey: wishlistProductsKey }, (old) =>
      old?.filter((p) => p.id !== productId),
    );

    return { previousIds, previousProducts };
  },

  onError: (_error, _vars, context) => {
    if (context?.previousIds) {
      queryClient.setQueryData(idsKey, context.previousIds);
    }
    for (const [key, data] of context?.previousProducts ?? []) {
      queryClient.setQueryData(key, data);
    }
  },

  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: idsKey });
    queryClient.invalidateQueries({ queryKey: wishlistProductsKey });
  },
});

Guard against double taps

Taps less than 300 ms apart are ignored, so a double tap can't add and then remove the same product:

const now = Date.now();
if (now - lastToggleRef.current < 300) return;
lastToggleRef.current = now;

When not to be optimistic

The rule

Be optimistic when the change is almost certain to succeed and touches one or two caches. If you're patching four caches, fix where the data lives first.

My first wishlist patched the product detail, every product list page, and the saved IDs, with a snapshot and rollback for each. Two of those caches even spelled the flag differently (isInWishlist and isInWishList). The file was 312 lines.

The fix was to make every heart icon read one query: the list of saved product IDs. After that, removing only had to patch two caches, and the file dropped to 160 lines.

Adding to the wishlist stopped being optimistic. The button is disabled while the request runs, and when the server confirms, a toast offers "View Wishlist". That button only makes sense once the product is really saved.

Last updated on

On this page