Skip to article
React 17 Feb 2026 · 10 min read

React Query v5 Advanced Patterns: Optimistic Updates, Mutations & Offline

Master TanStack Query v5's advanced features for optimistic UI, complex mutations, and offline-first applications.

SK

Suboor Khan

Full-Stack Developer

🔄
React

TanStack Query v5 shipped with breaking API changes, new patterns, and a dramatically improved offline experience. If you've been using v4 or earlier, this guide bridges the gap and goes deep on the patterns that actually matter in production: optimistic updates with automatic rollback, complex multi-step mutations, and offline-first support with persistence.

What Changed in v5

  • Single object API. All hooks now accept a single options object. useQuery(['key'], fn, opts)useQuery({queryKey: ['key'], queryFn: fn})
  • Strongly typed errors. error is typed as Error | null by default (not unknown).
  • useSuspenseQuery. First-class Suspense queries that guarantee data is defined (no undefined checks).
  • Removed callbacks from useQuery. onSuccess, onError, onSettled are removed — use useMutation or effects.
  • Improved mutation.variables. Variables are now available synchronously during mutation for optimistic rendering.

Optimistic Updates with Automatic Rollback

The gold standard: update the UI immediately, then sync with the server. If the server request fails, roll back automatically.

import { useMutation, useQueryClient } from '@tanstack/react-query';

interface Todo { id: string; title: string; completed: boolean; }

function useTodoToggle() {
  const qc = useQueryClient();

  return useMutation({
    mutationFn: (todo: Todo) =>
      fetch(`/api/todos/${todo.id}`, {
        method: 'PATCH',
        body: JSON.stringify({ completed: !todo.completed }),
      }).then(r => r.json()),

    // Step 1: Snapshot + optimistic update
    onMutate: async (todo) => {
      // Cancel any in-flight refetches (avoid race conditions)
      await qc.cancelQueries({ queryKey: ['todos'] });

      // Snapshot the current value for rollback
      const previous = qc.getQueryData<Todo[]>(['todos']);

      // Optimistically update the cache
      qc.setQueryData<Todo[]>(['todos'], old =>
        old?.map(t => t.id === todo.id ? { ...t, completed: !t.completed } : t)
      );

      return { previous };  // ← context passed to onError
    },

    // Step 2: On failure — roll back to snapshot
    onError: (_err, _todo, context) => {
      if (context?.previous) {
        qc.setQueryData(['todos'], context.previous);
      }
    },

    // Step 3: Always refetch to ensure server is the source of truth
    onSettled: () => {
      qc.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}

mutation.variables for Pending UI

In v5, mutation.variables is synchronously available while the mutation is pending. Combine with isIdle/isPending to show in-flight items immediately without touching the query cache.

function TodoList() {
  const { data: todos = [] } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
  const addMutation = useAddTodo();

  // Render optimistic pending item alongside real data
  return (
    <ul>
      {todos.map(t => <TodoItem key={t.id} todo={t} />)}

      {addMutation.isPending && (
        <TodoItem
          key="optimistic"
          todo={{ id: 'pending', title: addMutation.variables.title, completed: false }}
          isPending
        />
      )}
    </ul>
  );
}

Offline-First with persistQueryClient

import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: 1000 * 60 * 60 * 24,  // Keep cache for 24h (was cacheTime in v4)
      staleTime: 1000 * 60 * 5,     // Consider fresh for 5 min
      networkMode: 'offlineFirst',  // Use cache when offline
    },
    mutations: {
      networkMode: 'offlineFirst',  // Queue mutations when offline
    },
  },
});

const persister = createSyncStoragePersister({
  storage: window.localStorage,
  key: 'app-query-cache',
});

// Wrap your app
function App() {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{ persister, maxAge: 1000 * 60 * 60 * 24 }}
    >
      <YourApp />
    </PersistQueryClientProvider>
  );
}

With networkMode: 'offlineFirst', mutations are queued when offline and automatically retried when connectivity is restored.

Query Key Factories (Best Practice)

// lib/queryKeys.ts
export const todoKeys = {
  all:     ['todos']           as const,
  lists:   () => [...todoKeys.all, 'list']            as const,
  list:    (filters: Filters) => [...todoKeys.lists(), filters] as const,
  details: () => [...todoKeys.all, 'detail']          as const,
  detail:  (id: string)       => [...todoKeys.details(), id]    as const,
};

// Usage — consistent keys everywhere
const { data } = useQuery({
  queryKey: todoKeys.list({ status: 'open' }),
  queryFn:  () => fetchTodos({ status: 'open' }),
});

// Invalidate all todo lists
qc.invalidateQueries({ queryKey: todoKeys.lists() });

Summary

  • v5 unified single-object API — update all hook call sites when migrating
  • Optimistic updates: onMutate snapshot → onError rollback → onSettled invalidate
  • mutation.variables enables zero-cache pending UI for instantly interactive UX
  • persistQueryClient + offlineFirst = seamless offline mutations without extra code
  • Query key factories eliminate key inconsistency bugs across large codebases

Stay Updated

Deep-dive articles on React, AI, and software craft — twice a month.