effidevFlutter · Cloudflare edge · Cloud cost optimization
English

Practical TanStack Query v5: Optimistic Updates and Offline Caching Patterns

Architecture diagram showing TanStack Query v5 optimistic updates and offline caching

In modern web applications, User Experience (UX) is the core factor that determines the success of an application. Imagine if the UI reacted instantly instead of showing a loading spinner when a user clicks a button.

In this article, we will take a deep dive into implementing Optimistic Updates to provide a zero-latency experience using TanStack Query v5 (formerly React Query), and explore Offline Caching Patterns in case of network disconnects.

1. What are Optimistic Updates?

Optimistic updates are a technique where the UI is updated immediately, optimistically assuming the request will succeed, without waiting for the server’s response.

In TanStack Query v5, you can handle this rollback mechanism elegantly through the lifecycle callbacks of the useMutation hook (onMutate, onError, onSettled).

[!NOTE] In v5, the context object returned from onMutate is passed to onError and onSettled and used for rollbacks.

2. Implementing Optimistic Updates with TanStack Query v5

Let’s look at a simple ‘Like’ toggle feature as an example.

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

// Post type definition
interface Post {
  id: string;
  title: string;
  likes: number;
}

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

  return useMutation({
    mutationFn: (postId: string) => fetch(`/api/posts/${postId}/like`, { method: 'POST' }),
    
    // onMutate: Called before the mutation function is executed
    onMutate: async (newPostId) => {
      // 1. Cancel any outgoing refetches to avoid overwriting our optimistic update
      await queryClient.cancelQueries({ queryKey: ['posts', newPostId] });

      // 2. Snapshot the previous value for recovery on error
      const previousPost = queryClient.getQueryData<Post>(['posts', newPostId]);

      // 3. Optimistically update the cache
      if (previousPost) {
        queryClient.setQueryData<Post>(['posts', newPostId], {
          ...previousPost,
          likes: previousPost.likes + 1,
        });
      }

      // 4. Return a context with the previous data
      return { previousPost };
    },
    
    // onError: Rollback to the previous state using the context returned from onMutate
    onError: (err, newPostId, context) => {
      if (context?.previousPost) {
        queryClient.setQueryData(['posts', newPostId], context.previousPost);
      }
    },
    
    // onSettled: Invalidate queries to ensure fresh data regardless of success or failure
    onSettled: (newPostId) => {
      queryClient.invalidateQueries({ queryKey: ['posts', newPostId] });
    },
  });
}

Key Points Analysis

  1. cancelQueries: Prevents optimistic updates from being overwritten by stale data arriving from a delayed fetch.
  2. previousPost Snapshot: Saves the original state before modifying the cache.
  3. Context Return: The value returned by onMutate is passed as the third argument to onError for use in rollbacks.

3. Offline-First and Caching Patterns

To prevent users from seeing a blank screen when offline or on an unstable network, a proper caching strategy is required. TanStack Query v5 provides robust offline mechanisms by default.

3.1 Configuring Network Mode

TanStack Query’s default networkMode is online. This means that if there is no network connection, queries and mutations will pause and wait.

If you are developing an offline-first app, it’s recommended to use the offlineFirst setting to show cached data while attempting to sync upon reconnection.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      networkMode: 'offlineFirst',
      staleTime: 1000 * 60 * 5, // 5 minutes
    },
  },
});

3.2 Persistent Caching with Persisters

To retain data even after refreshing or closing and reopening the browser, you need a Persister plugin. In v5, asynchronous persisters based on @tanstack/query-sync-storage-persister or idb-keyval (IndexedDB) are commonly used.

Example of cache persistence using IndexedDB:

import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { get, set, del } from 'idb-keyval';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      cacheTime: 1000 * 60 * 60 * 24, // Keep cache for 24 hours
    },
  },
});

const indexedDBPersister = createAsyncStoragePersister({
  storage: {
    getItem: async (key) => await get(key),
    setItem: async (key, value) => await set(key, value),
    removeItem: async (key) => await del(key),
  },
});

// Wrap with Provider at the top of your App
function App() {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{ persister: indexedDBPersister }}
    >
      <YourApp />
    </PersistQueryClientProvider>
  );
}

4. Practical Edge Cases and Architectural Decisions

When applying optimistic updates and offline caching in production, there are a few edge cases to watch out for.

4.1 Rapid Consecutive Optimistic Updates

Think of a scenario where a user rapidly clicks the ‘like’ button multiple times. If onMutate fires every time, the snapshot might capture an intermediate (already mutated) state.

Solutions:

Issue Cause Solution Pattern
Rollback failure Missing cache snapshot before onMutate Always return the queryClient.getQueryData snapshot as context
Data overwrite In-flight GET arrives after POST Place queryClient.cancelQueries at the top of onMutate
List UI flicker Mismatch between temp ID and server ID Replace the temp object with the server response object using map

Summary