DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

TanStack Query Advanced Patterns: Optimistic Updates & Cache Management

TanStack Query Advanced Patterns: Optimistic Updates & Cache Management

Most React developers stop at useQuery and useMutation. They fetch data, show a spinner, handle errors. Ship it. But when you're building CitizenApp with 9 AI features and concurrent user actions, basic patterns crumble. Users see loading states that shouldn't exist. Cache gets stale. Real-time features lag. I've rebuilt our mutation strategy three times. Here's what actually works in production.

Why Optimistic Updates Matter More Than You Think

The difference between a good product and an annoying one is often invisible: perceived latency. When a user submits a form, they expect instant feedback. If you wait for the server, you've already lost them to the spinning wheel.

I learned this the hard way. In CitizenApp's early days, we had users click "Generate AI Response" and stare at loaders for 3–5 seconds while the Claude API processed. Same latency, but with optimistic updates? Users immediately see their action registered. The wait feels less painful because something changed.

Here's the real pattern:

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

export function useUpdateCitizen(citizenId: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (updates: Partial<Citizen>) => {
      const res = await fetch(`/api/citizens/${citizenId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(updates),
      });
      if (!res.ok) throw new Error('Update failed');
      return res.json();
    },

    // Optimistic update: update cache BEFORE server responds
    onMutate: async (updates) => {
      // Cancel ongoing queries so they don't overwrite our optimistic data
      await queryClient.cancelQueries({
        queryKey: ['citizen', citizenId],
      });

      // Snapshot old data in case we need to rollback
      const previousCitizen = queryClient.getQueryData<Citizen>([
        'citizen',
        citizenId,
      ]);

      // Immediately update the cache
      queryClient.setQueryData(
        ['citizen', citizenId],
        (old: Citizen) => ({
          ...old,
          ...updates,
        })
      );

      return { previousCitizen };
    },

    // On success, refetch to ensure server truth wins
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: ['citizen', citizenId],
      });
    },

    // On error, rollback to the snapshot
    onError: (err, variables, context) => {
      if (context?.previousCitizen) {
        queryClient.setQueryData(
          ['citizen', citizenId],
          context.previousCitizen
        );
      }
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

This pattern does three things:

  1. Cancels in-flight queries so a stale refetch doesn't overwrite your optimistic data
  2. Snapshots the old state for rollback if things fail
  3. Invalidates after success so you sync with server truth, not just trust your prediction

Use it like this:

export function CitizenForm({ citizenId }: { citizenId: string }) {
  const { data: citizen } = useQuery({
    queryKey: ['citizen', citizenId],
    queryFn: () => fetch(`/api/citizens/${citizenId}`).then(r => r.json()),
  });

  const updateMutation = useUpdateCitizen(citizenId);

  const handleNameChange = (name: string) => {
    updateMutation.mutate({ name });
  };

  return (
    <input
      defaultValue={citizen?.name}
      onChange={(e) => handleNameChange(e.target.value)}
      disabled={updateMutation.isPending}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

The input updates instantly because setQueryData changes what useQuery returns. No spinner. The server request happens in the background. If it fails, we rollback. If it succeeds, we refetch to catch any server-side calculations (e.g., updated modifiedAt timestamps).

Cache Strategy for Multi-Tenant Apps

Here's where most teams stumble: tenant isolation. If you have multiple workspaces/organizations, mixing cache between them is a security and data-correctness nightmare.

I prefer a scoped cache key strategy:

// lib/queryKeys.ts
export const queryKeys = {
  citizens: (tenantId: string) => [
    'tenants',
    tenantId,
    'citizens',
  ] as const,
  citizen: (tenantId: string, citizenId: string) => [
    'tenants',
    tenantId,
    'citizens',
    citizenId,
  ] as const,
  aiResponses: (tenantId: string, citizenId: string) => [
    'tenants',
    tenantId,
    'citizens',
    citizenId,
    'ai-responses',
  ] as const,
};
Enter fullscreen mode Exit fullscreen mode

This forces you to always scope queries by tenant. If you switch orgs, you invalidate everything:

// hooks/useSwitchTenant.ts
export function useSwitchTenant() {
  const queryClient = useQueryClient();

  return (newTenantId: string) => {
    // Clear all cached data for the old tenant
    queryClient.removeQueries();

    // Or more precisely, remove only the old tenant's data
    queryClient.removeQueries({
      predicate: (query) => {
        const [scope, tenantId] = query.queryKey;
        return scope === 'tenants' && tenantId !== newTenantId;
      },
    });
  };
}
Enter fullscreen mode Exit fullscreen mode

Why? Because I've seen race conditions where users switched orgs, the UI updated, but queries from the old org were still in-flight and overwrote the new org's data. Scoped keys + explicit invalidation prevents that entirely.

Real-Time Sync Without Chaos

The moment you add WebSockets or Server-Sent Events, cache invalidation becomes harder. You can't just invalidateQueries on every server message—you'll thrash.

// hooks/useRealTimeUpdates.ts
export function useRealTimeUpdates(tenantId: string) {
  const queryClient = useQueryClient();

  useEffect(() => {
    const eventSource = new EventSource(
      `/api/tenants/${tenantId}/updates?token=${getToken()}`
    );

    eventSource.addEventListener('citizen-updated', (event) => {
      const { citizenId, changes } = JSON.parse(event.data);

      // Surgical update: only update the specific citizen
      queryClient.setQueryData(
        queryKeys.citizen(tenantId, citizenId),
        (old: Citizen) => ({ ...old, ...changes })
      );

      // If it's a list update, update the list too
      queryClient.setQueryData(
        queryKeys.citizens(tenantId),
        (old: Citizen[]) =>
          old?.map((c) => (c.id === citizenId ? { ...c, ...changes } : c))
      );
    });

    return () => eventSource.close();
  }, [tenantId, queryClient]);
}
Enter fullscreen mode Exit fullscreen mode

Don't invalidate the whole list. Surgically update the specific item. This preserves pagination, sort order, and filters. The UI updates instantly without refetching.

Gotcha: Mutation Lifecycle Timing

Here's what burned me: if you call invalidateQueries in onSuccess and the refetch takes time, users see the optimistic update flicker away briefly while the refetch loads.

// ❌ This causes flicker
onSuccess: () => {
  queryClient.invalidateQueries({ queryKey: ['citizens'] });
}

// ✅ Better: let the mutation result populate the cache directly
onSuccess: (data) => {
  queryClient.setQueryData(
    ['citizen', citizenId],
    data
  );
  // Only invalidate related lists
  queryClient.invalidateQueries({
    queryKey: ['citizens'],
    exact: false,
  });
}
Enter fullscreen mode Exit fullscreen mode

The second approach uses the server response directly, so the cache is already "refreshed" before refetches happen. Less flickering, snappier feel.

What I Missed Early On

I used to think TanStack Query was just a fetch wrapper. It's not. It's a state machine for async data. The real power is the cache layer. Once you grok that mutations should update cache first, not after, everything clicks. Optimistic updates aren't fancy—they're the default way async should work in 2024.

Build your app assuming latency. Users are more forgiving of loading when something already changed.

Top comments (0)