Skip to content

Frontend

Optimistic UI Updates — TanStack Query Mutations That Feel Instant

Waiting for the server is a design failure. Every form submission, button click, and status toggle forces the user to stare at a loading spinner while you make a round-trip to the backend. Optimistic UI updates solve it: update the cache immediately, let the user see the change, then sync with the server in the background. If the server rejects it, revert silently. TanStack Query's `useMutation` with `onMutate` hooks makes this pattern trivial—one function, three callbacks, instant feedback. This is how production dashboards feel snappy.

🎯

The default mutation pattern is broken. User clicks "Delete", a loading spinner spins for 800ms while you make a POST to the backend, the item disappears, the user wonders if it worked. This delay is artificial. You've already rendered the app, you know what the mutation should do, so why make the user wait? Optimistic UI updates flip the order: show the change immediately, confirm with the server asynchronously, revert only if something breaks. Users see instant feedback. Network latency vanishes. The UX feels responsive, even on slow connections. This is a solved problem in TanStack Query—ship optimistic mutations with useMutation + onMutate + onError callbacks. Every Velocity dashboard uses this pattern for deletes, toggles, reorders, and status changes. It's non-negotiable.

The Problem: Mutations That Block the UI

Here's how most engineers write mutations:

// src/hooks/useDeleteRoute.ts — slow
import { useMutation } from '@tanstack/react-query';

export function useDeleteRoute() {
  return useMutation({
    mutationFn: async (routeId) => {
      const res = await fetch(`/api/routes/${routeId}`, {
        method: 'DELETE',
      });
      return res.json();
    },
  });
}

In a component:

// src/components/RouteItem.tsx
export function RouteItem({ route }) {
  const deleteRoute = useDeleteRoute();

  return (
    <div>
      <span>{route.name}</span>
      <button
        disabled={deleteRoute.isPending}
        onClick={() => deleteRoute.mutate(route.id)}
      >
        {deleteRoute.isPending ? 'Deleting...' : 'Delete'}
      </button>
    </div>
  );
}

User clicks Delete. The button grays out, "Deleting..." shows, and you wait for the server. The item stays in the list until the response arrives. If the network is slow, the user sees the spinner for 2+ seconds. If they accidentally deleted it, tough luck — there's no instant undo. Even worse, the item is still in the cache until the mutation completes. Refresh the page and it's back. This is the worst UX you can ship.

The Pattern: Optimistic Updates in Three Callbacks

TanStack Query mutations support three lifecycle callbacks: onMutate, onError, and onSuccess. Use them:

// src/hooks/useDeleteRoute.ts — optimistic
import { useMutation, useQueryClient } from '@tanstack/react-query';

export function useDeleteRoute() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (routeId) => {
      const res = await fetch(`/api/routes/${routeId}`, {
        method: 'DELETE',
      });
      if (!res.ok) throw new Error('Failed to delete');
      return res.json();
    },
    onMutate: async (routeId) => {
      // Cancel pending queries so they don't overwrite our optimistic update
      await queryClient.cancelQueries({ queryKey: ['routes'] });

      // Snapshot the current cache
      const previousRoutes = queryClient.getQueryData(['routes']);

      // Update cache optimistically
      queryClient.setQueryData(['routes'], (old) =>
        old?.filter(r => r.id !== routeId)
      );

      // Return context for rollback
      return { previousRoutes };
    },
    onError: (err, routeId, context) => {
      // Revert to snapshot if mutation fails
      queryClient.setQueryData(['routes'], context.previousRoutes);
    },
    onSuccess: () => {
      // Refetch to sync with server (optional, cache is already correct)
      queryClient.invalidateQueries({ queryKey: ['routes'] });
    },
  });
}

Now in the component, the item disappears instantly:

// src/components/RouteItem.tsx
export function RouteItem({ route }) {
  const deleteRoute = useDeleteRoute();

  return (
    <div>
      <span>{route.name}</span>
      <button
        onClick={() => deleteRoute.mutate(route.id)}
      >
        Delete
      </button>
    </div>
  );
}

User clicks Delete. The item vanishes immediately. In the background, the mutation hits the server. If it succeeds, great — the item is gone. If it fails (permission error, validation error, network timeout), the item appears again silently. The user never saw the error because the item was never truly deleted. They might refresh the page to double-check, but the item reappears, so no confusion. This is UX magic.

Three Real Examples: Delete, Toggle, Reorder

Example 1: Optimistic Delete

Already covered above, but the key steps are: snapshot the cache in onMutate, filter the item out, return the snapshot for onError. If the delete fails, the snapshot reverts the cache and the item reappears. Users never see inconsistent state.

Example 2: Optimistic Toggle (Checkbox / Switch)

Toggle a boolean field optimistically:

// src/hooks/useToggleRouteActive.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';

export function useToggleRouteActive() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ routeId, newValue }) => {
      const res = await fetch(`/api/routes/${routeId}`, {
        method: 'PATCH',
        body: JSON.stringify({ is_active: newValue }),
      });
      return res.json();
    },
    onMutate: async ({ routeId, newValue }) => {
      await queryClient.cancelQueries({ queryKey: ['routes'] });
      const previousRoutes = queryClient.getQueryData(['routes']);

      queryClient.setQueryData(['routes'], (old) =>
        old?.map(r =>
          r.id === routeId ? { ...r, is_active: newValue } : r
        )
      );

      return { previousRoutes };
    },
    onError: (err, variables, context) => {
      queryClient.setQueryData(['routes'], context.previousRoutes);
    },
  });
}

In a component:

// src/components/RouteToggle.tsx
export function RouteToggle({ route }) {
  const toggleActive = useToggleRouteActive();

  return (
    <input
      type="checkbox"
      checked={route.is_active}
      onChange={(e) =>
        toggleActive.mutate({
          routeId: route.id,
          newValue: e.target.checked,
        })
      }
    />
  );
}

Checkbox changes instantly. The mutation syncs in the background. If it fails, the checkbox reverts. Users never stare at a loading state for a toggle.

Example 3: Optimistic Reorder (Drag-and-Drop)

Reordering is the most complex case because you're mutating order for multiple items:

// src/hooks/useReorderRoutes.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';

export function useReorderRoutes() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (newOrder) => {
      const res = await fetch('/api/routes/reorder', {
        method: 'POST',
        body: JSON.stringify({ order: newOrder }),
      });
      return res.json();
    },
    onMutate: async (newOrder) => {
      await queryClient.cancelQueries({ queryKey: ['routes'] });
      const previousRoutes = queryClient.getQueryData(['routes']);

      // newOrder is array of { id, order_index }
      queryClient.setQueryData(['routes'], (old) => {
        const orderMap = new Map(newOrder.map(o => [o.id, o.order_index]));
        return old
          ?.map(r => ({ ...r, order_index: orderMap.get(r.id) ?? r.order_index }))
          .sort((a, b) => a.order_index - b.order_index);
      });

      return { previousRoutes };
    },
    onError: (err, newOrder, context) => {
      queryClient.setQueryData(['routes'], context.previousRoutes);
    },
  });
}

User drags a route. The list reorders instantly. The mutation batches all the order changes and sends them to the server. If the server rejects (due to concurrent reorders by another user), the list snaps back to the previous order. No jank, no confusion.

Conflict Resolution: What Happens When Two Users Mutate the Same Data

Here's the hard case: User A reorders routes, User B toggles a route status, both mutations race. In a naive optimistic system, one mutation might overwrite the other's changes. TanStack Query's queryClient.invalidateQueries() in onSuccess solves this: after the mutation succeeds, refetch the query to sync with the server. The server is the source of truth. Your UI always reflects it.

For high-frequency dashboards (Rebuild Relief's location-sorting tool with 20+ concurrent users), combine optimistic updates with Supabase realtime. When User B's mutation broadcasts via Realtime, User A's query refetches and merges both changes. Everyone's in sync.

Six FAQs

Should I always use optimistic updates?

Yes, for fast mutations (toggles, deletes, reorders). Skip for slow operations (image uploads, large file processing) where the latency is unavoidable anyway. Optimistic updates shine when latency is artificial.

What if the server validation fails?

The onError callback reverts the cache. The user sees the item reappear or the toggle flip back. You can also show a toast: onError: (err) => toast.error(err.message).

Can I show a loading state during optimistic updates?

Yes. The mutation is still pending while the server responds. Use isPending to gray out the button or show a subtle indicator. The data is already updated optimistically, so the UI is responsive even while waiting.

How do I handle partial failures (some items succeed, others fail)?

Batch mutations carefully. If reordering 10 routes and only 5 succeed, the server should return the successful state, and your onSuccess refetch merges it. Or use invalidateQueries to resync everything after the batch completes.

Does optimistic update work with Supabase?

Yes. Call Supabase's .update() or .delete() inside mutationFn, use the same onMutate + onError pattern, and optionally subscribe to Realtime for live conflict resolution. See the TanStack Query post for Realtime examples.

Can I test optimistic mutations?

Absolutely. Wrap your test in QueryClientProvider, mock the mutationFn, assert the cache state in onMutate, and verify rollback in onError. TanStack Query's test utilities handle this. The pattern is deterministic and easy to verify.

The Bottom Line

Optimistic UI updates are the fastest way to ship responsive mutations. Show the change instantly, sync with the server asynchronously, revert only if something breaks. TanStack Query's useMutation with onMutate, onError, and onSuccess callbacks handle all the complexity. For dashboards with dozens of concurrent users, this pattern is mandatory. Combine it with Aidxn's full stack (Supabase, Realtime, TanStack Query) and your app feels instant. Ship it.

Let us make some quick suggestions?

Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.