DevToolBoxFREE
Blog

React Query Patterns 2026: Data Fetching, Caching, and Mutations with TanStack Query

13 min readby DevToolBox

TanStack Query (formerly React Query) is the standard solution for server state management in React applications. It handles caching, background refetching, deduplication, pagination, and infinite queries — all with a simple hooks-based API. In 2026, TanStack Query v5 is the industry standard, replacing manual useEffect+useState patterns for data fetching.

useQuery: Fetching and Caching Data

useQuery subscribes to a server state. The queryKey is the cache key — any array that uniquely identifies the data. staleTime controls when data is considered stale and refetched.

import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query';

// Setup: wrap your app
const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            staleTime: 1000 * 60 * 5,  // 5 minutes
            gcTime: 1000 * 60 * 30,     // 30 minutes (formerly cacheTime)
            retry: 3,
            refetchOnWindowFocus: true,
        },
    },
});

function App() {
    return (
        <QueryClientProvider client={queryClient}>
            <MyApp />
        </QueryClientProvider>
    );
}

// Basic query
interface User {
    id: number;
    name: string;
    email: string;
}

function useUser(userId: number) {
    return useQuery<User>({
        queryKey: ['user', userId],      // cache key — must be unique
        queryFn: async () => {
            const res = await fetch(`/api/users/${userId}`);
            if (!res.ok) throw new Error('Failed to fetch user');
            return res.json();
        },
        enabled: userId > 0,            // only run if userId is valid
        staleTime: 1000 * 60 * 10,     // override global default
    });
}

function UserProfile({ userId }: { userId: number }) {
    const { data, isLoading, isError, error } = useUser(userId);

    if (isLoading) return <div>Loading...</div>;
    if (isError) return <div>Error: {error.message}</div>;

    return <div>{data.name} — {data.email}</div>;
}

useMutation: Creating, Updating, Deleting

useMutation handles create/update/delete operations. onSuccess callbacks let you invalidate related queries or directly update the cache without a refetch.

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

interface CreatePostInput {
    title: string;
    body: string;
    userId: number;
}

function useCreatePost() {
    const queryClient = useQueryClient();

    return useMutation({
        mutationFn: async (input: CreatePostInput) => {
            const res = await fetch('/api/posts', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(input),
            });
            if (!res.ok) throw new Error('Failed to create post');
            return res.json();
        },
        onSuccess: (newPost) => {
            // Invalidate and refetch posts list
            queryClient.invalidateQueries({ queryKey: ['posts'] });
            // Or add directly to cache (no refetch needed)
            queryClient.setQueryData(['post', newPost.id], newPost);
        },
        onError: (error) => {
            console.error('Failed to create post:', error);
        },
    });
}

function CreatePostForm() {
    const mutation = useCreatePost();

    const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        mutation.mutate({ title: 'New Post', body: 'Content', userId: 1 });
    };

    return (
        <form onSubmit={handleSubmit}>
            <button type="submit" disabled={mutation.isPending}>
                {mutation.isPending ? 'Creating...' : 'Create Post'}
            </button>
            {mutation.isError && <p>Error: {mutation.error.message}</p>}
            {mutation.isSuccess && <p>Post created!</p>}
        </form>
    );
}

Optimistic Updates for Instant UI

Optimistic updates apply changes to the UI immediately before the server responds. If the request fails, the onError callback rolls back to the previous state.

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

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

function useToggleTodo() {
    const queryClient = useQueryClient();

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

        // Optimistic update — runs BEFORE the mutation
        onMutate: async (updatedTodo) => {
            // Cancel outgoing refetches
            await queryClient.cancelQueries({ queryKey: ['todos'] });

            // Snapshot current data for rollback
            const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

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

            return { previousTodos }; // context for onError
        },

        // If mutation fails, roll back to snapshot
        onError: (_err, _variables, context) => {
            if (context?.previousTodos) {
                queryClient.setQueryData(['todos'], context.previousTodos);
            }
        },

        // Always refetch after error or success
        onSettled: () => {
            queryClient.invalidateQueries({ queryKey: ['todos'] });
        },
    });
}

Infinite Queries for Pagination

useInfiniteQuery handles cursor-based and offset-based pagination. getNextPageParam extracts the next page cursor from each page response.

import { useInfiniteQuery } from '@tanstack/react-query';

interface Page {
    items: Post[];
    nextCursor?: string;
}

function useInfinitePosts() {
    return useInfiniteQuery<Page>({
        queryKey: ['posts', 'infinite'],
        queryFn: async ({ pageParam }) => {
            const url = pageParam
                ? `/api/posts?cursor=${pageParam}`
                : '/api/posts';
            return fetch(url).then(r => r.json());
        },
        initialPageParam: undefined as string | undefined,
        getNextPageParam: (lastPage) => lastPage.nextCursor,
    });
}

function InfinitePostList() {
    const {
        data,
        fetchNextPage,
        hasNextPage,
        isFetchingNextPage,
    } = useInfinitePosts();

    return (
        <div>
            {data?.pages.flatMap(page => page.items).map(post => (
                <div key={post.id}>{post.title}</div>
            ))}
            {hasNextPage && (
                <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
                    {isFetchingNextPage ? 'Loading...' : 'Load more'}
                </button>
            )}
        </div>
    );
}

Query Key Patterns and Prefetching

Consistent query key factories prevent cache mismatches. Prefetching on hover creates instant page transitions — the data is already in cache when the user clicks.

// Query key patterns — factory functions for consistency
const queryKeys = {
    all: ['posts'] as const,
    lists: () => [...queryKeys.all, 'list'] as const,
    list: (filters: string) => [...queryKeys.lists(), { filters }] as const,
    details: () => [...queryKeys.all, 'detail'] as const,
    detail: (id: number) => [...queryKeys.details(), id] as const,
};

// Usage:
useQuery({ queryKey: queryKeys.detail(1), queryFn: ... });

// Invalidate all posts:
queryClient.invalidateQueries({ queryKey: queryKeys.all });

// Invalidate only lists:
queryClient.invalidateQueries({ queryKey: queryKeys.lists() });

// Prefetching for better UX
async function prefetchUser(userId: number) {
    await queryClient.prefetchQuery({
        queryKey: ['user', userId],
        queryFn: () => fetchUser(userId),
        staleTime: 10 * 60 * 1000, // only prefetch if > 10 min stale
    });
}

// On hover: prefetch before click
<link onMouseEnter={() => prefetchUser(userId)} href={`/users/${userId}`}>
    View Profile
</link>

TanStack Query vs Other Solutions

FeatureTanStack QueryRedux ToolkitSWRApollo
CachingBuilt-in (staleTime, gcTime)ManualBuilt-inBuilt-in (Apollo-only)
Optimistic updatesFirst-classManualBasicFirst-class
Infinite queryuseInfiniteQueryManualuseSWRInfinitefetchMore
MutationsuseMutationRTK QueryNo (use fetch)useMutation
DevToolsExcellentExcellentBasicGood
REST + GraphQLBothBothREST focusedGraphQL only

Best Practices

  • Use query key factories (objects with functions) to ensure consistency and enable surgical cache invalidation.
  • Set staleTime based on data freshness requirements. User profiles: 10 minutes. Stock prices: 0 seconds. Reference data: 1 hour.
  • Always provide loading and error states. Use React Suspense mode for cleaner code with Suspense boundaries.
  • Use optimistic updates for toggle operations, likes, and other quick mutations — it makes apps feel instant.
  • Prefetch data on hover or route transitions. TanStack Query's prefetchQuery is idempotent — safe to call multiple times.

Frequently Asked Questions

What is the difference between staleTime and gcTime?

staleTime controls how long cached data is considered fresh (no refetch needed). gcTime (formerly cacheTime) controls how long inactive cache entries are kept in memory before garbage collection. A query with staleTime=5m and gcTime=30m: fresh for 5 minutes, then background-refetched, but kept in memory for 30 minutes even when no components subscribe to it.

How does TanStack Query handle race conditions?

TanStack Query deduplicates identical requests automatically — if 10 components request the same query key simultaneously, only 1 network request fires. Responses are shared. For mutations, use onSettled to always invalidate after completion, regardless of success or failure.

Should I use TanStack Query with Redux or Zustand?

TanStack Query handles server state (remote data); Redux/Zustand handles client state (UI state, selections, form drafts). They complement each other — use TanStack Query for API calls and Zustand for local UI state. Most apps don't need Redux when using TanStack Query.

How do I handle authentication errors globally?

Use the QueryClient's global onError callback or a custom query function wrapper. For 401s, redirect to login: queryClient.setDefaultOptions({ queries: { onError: (err) => { if (err.status === 401) router.push("/login"); } } })

What changed in TanStack Query v5?

v5 (released 2023) renamed cacheTime to gcTime, made the query function parameter an object (queryKey and signal), removed the success/error/settle callbacks from useQuery (use separate useEffect), added support for SSR with React Server Components, and improved TypeScript inference.

Related Tools

𝕏 Twitterin LinkedIn
Was this helpful?

Stay Updated

Get weekly dev tips and new tool announcements.

No spam. Unsubscribe anytime.

Try These Related Tools

{ }JSON FormatterB→Base64 Encode OnlineIDUUID Generator Online

Related Articles

React Server Components: Complete Guide for 2026

Master React Server Components (RSC): architecture, data fetching, streaming, client vs server boundaries, and migration strategies.

TypeScript Type Guards: Complete Guide to Runtime Type Checking

Master TypeScript type guards: typeof, instanceof, in operator, custom type guards, discriminated unions, and assertion functions.

Next.js App Router: Complete Migration Guide 2026

Master the Next.js App Router with this comprehensive guide. Learn Server Components, data fetching, layouts, streaming, Server Actions, and step-by-step migration from Pages Router.