Back to Blog

React Query Patterns in 2026: Query Invalidation, Optimistic Updates

Master TanStack Query v5 patterns: query key factories, cache invalidation strategies, optimistic mutations, infinite scroll with useInfiniteQuery, prefetching, and server-state architecture.

Viprasol Tech Team
13 min read
Updated 2026

React Query Patterns in 2026: Query Invalidation, Optimistic Updates, and Infinite Scroll

Quick answer. Production TanStack Query codebases rely on query key factories to centralize keys and make cache invalidation predictable, optimistic mutations that feel instantaneous, and infinite queries that avoid re-fetching the entire list on mutation. Ad-hoc keys scattered across components are what break naive useQuery usage at scale.

TanStack Query (formerly React Query) has become the standard for server-state management in React applications. The common usage pattern โ€” slap useQuery on every data fetch โ€” works fine for simple cases but breaks down at scale. The patterns that distinguish production codebases from demos: query key factories that make cache invalidation predictable, optimistic mutations that feel instantaneous, and infinite queries that don't re-fetch the entire list on mutation.

This post covers the patterns we use in production applications.


Query Key Factories

Query keys are the foundation of TanStack Query's cache. Ad-hoc keys like ['users', userId] scattered across components make cache invalidation unpredictable. Query key factories centralize key construction:

// src/lib/query-keys.ts
// Centralized query key factory โ€” all cache keys defined in one place

export const queryKeys = {
  // Users
  users: {
    all: () => ['users'] as const,
    lists: () => [...queryKeys.users.all(), 'list'] as const,
    list: (filters: UserFilters) => [...queryKeys.users.lists(), filters] as const,
    details: () => [...queryKeys.users.all(), 'detail'] as const,
    detail: (id: string) => [...queryKeys.users.details(), id] as const,
  },

  // Posts
  posts: {
    all: () => ['posts'] as const,
    lists: () => [...queryKeys.posts.all(), 'list'] as const,
    list: (filters: PostFilters) => [...queryKeys.posts.lists(), filters] as const,
    detail: (id: string) => [...queryKeys.posts.all(), 'detail', id] as const,
    comments: (postId: string) => [...queryKeys.posts.detail(postId), 'comments'] as const,
  },

  // Orders
  orders: {
    all: () => ['orders'] as const,
    byUser: (userId: string) => [...queryKeys.orders.all(), 'user', userId] as const,
    detail: (id: string) => [...queryKeys.orders.all(), 'detail', id] as const,
  },
} as const;

// Usage:
// queryClient.invalidateQueries({ queryKey: queryKeys.posts.lists() })
// โ†’ invalidates ALL post lists (any filter combination)
// โ†’ does NOT invalidate post details

Query Hooks with Error Boundaries

// src/hooks/queries/useUser.ts
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';
import { api } from '@/lib/api';

// Standard query hook (handles loading/error state inline)
export function useUser(userId: string) {
  return useQuery({
    queryKey: queryKeys.users.detail(userId),
    queryFn: () => api.users.get(userId),
    enabled: !!userId,        // Don't fetch if userId is empty
    staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
    gcTime: 10 * 60 * 1000,   // Keep in cache for 10 minutes (formerly cacheTime)
    retry: (failureCount, error) => {
      // Don't retry 404s โ€” user doesn't exist
      if ((error as any)?.status === 404) return false;
      return failureCount < 3;
    },
  });
}

// Suspense variant (throws to nearest Suspense boundary)
export function useSuspenseUser(userId: string) {
  return useSuspenseQuery({
    queryKey: queryKeys.users.detail(userId),
    queryFn: () => api.users.get(userId),
    staleTime: 5 * 60 * 1000,
  });
}

// Usage with Suspense + Error Boundary:
// <ErrorBoundary fallback={<Error />}>
//   <Suspense fallback={<Skeleton />}>
//     <UserProfile userId={id} />
//   </Suspense>
// </ErrorBoundary>
//
// function UserProfile({ userId }) {
//   const { data: user } = useSuspenseUser(userId); // never undefined
//   return <div>{user.name}</div>;
// }

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 1000+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Cache Invalidation Strategies

// src/hooks/mutations/useUpdateUser.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';
import { api } from '@/lib/api';

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

  return useMutation({
    mutationFn: ({ userId, data }: { userId: string; data: UpdateUserInput }) =>
      api.users.update(userId, data),

    onSuccess: (updatedUser) => {
      // Strategy 1: Update cache directly (no refetch needed)
      queryClient.setQueryData(
        queryKeys.users.detail(updatedUser.id),
        updatedUser,
      );

      // Strategy 2: Invalidate list queries (they'll refetch on next access)
      queryClient.invalidateQueries({
        queryKey: queryKeys.users.lists(),
      });

      // Don't invalidate the detail โ€” we already updated it above
    },

    onError: (error) => {
      console.error('Failed to update user:', error);
    },
  });
}

// More targeted invalidation patterns:
export function useDeletePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (postId: string) => api.posts.delete(postId),

    onSuccess: (_, postId) => {
      // Remove from cache entirely (rather than refetching a deleted resource)
      queryClient.removeQueries({
        queryKey: queryKeys.posts.detail(postId),
      });

      // Invalidate all post lists โ€” the deleted post needs to disappear
      queryClient.invalidateQueries({
        queryKey: queryKeys.posts.lists(),
      });
    },
  });
}

Optimistic Updates

Optimistic updates make mutations feel instantaneous by updating the cache before the server responds:

// src/hooks/mutations/useToggleLike.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';

interface Post {
  id: string;
  title: string;
  likeCount: number;
  isLikedByCurrentUser: boolean;
}

export function useToggleLike(postId: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (isLiked: boolean) =>
      isLiked ? api.posts.unlike(postId) : api.posts.like(postId),

    onMutate: async (currentlyLiked: boolean) => {
      // 1. Cancel in-flight queries to prevent race conditions
      await queryClient.cancelQueries({
        queryKey: queryKeys.posts.detail(postId),
      });

      // 2. Snapshot current value for rollback
      const previous = queryClient.getQueryData<Post>(queryKeys.posts.detail(postId));

      // 3. Optimistically update the cache
      queryClient.setQueryData<Post>(
        queryKeys.posts.detail(postId),
        (old) => old ? {
          ...old,
          isLikedByCurrentUser: !currentlyLiked,
          likeCount: old.likeCount + (currentlyLiked ? -1 : 1),
        } : old,
      );

      // 4. Return snapshot for rollback in onError
      return { previous };
    },

    onError: (_err, _variables, context) => {
      // Roll back to previous value if mutation failed
      if (context?.previous) {
        queryClient.setQueryData(
          queryKeys.posts.detail(postId),
          context.previous,
        );
      }
    },

    onSettled: () => {
      // Always refetch to ensure server state is authoritative
      queryClient.invalidateQueries({
        queryKey: queryKeys.posts.detail(postId),
      });
    },
  });
}

react - React Query Patterns in 2026: Query Invalidation, Optimistic Updates

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Infinite Scroll with useInfiniteQuery

// src/hooks/queries/useInfinitePosts.ts
import { useInfiniteQuery } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';

interface PostsPage {
  posts: Post[];
  nextCursor: string | null;
  total: number;
}

export function useInfinitePosts(filters: PostFilters = {}) {
  return useInfiniteQuery({
    queryKey: queryKeys.posts.list(filters),
    queryFn: ({ pageParam }) =>
      api.posts.list({ ...filters, cursor: pageParam, limit: 20 }),
    initialPageParam: null as string | null,
    getNextPageParam: (lastPage: PostsPage) => lastPage.nextCursor,
    staleTime: 2 * 60 * 1000,
  });
}
// src/components/PostFeed.tsx
import { useInfinitePosts } from '@/hooks/queries/useInfinitePosts';
import { useIntersectionObserver } from '@/hooks/useIntersectionObserver';

export function PostFeed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
  } = useInfinitePosts();

  // Auto-fetch when sentinel element enters viewport
  const sentinelRef = useIntersectionObserver(
    (isIntersecting) => {
      if (isIntersecting && hasNextPage && !isFetchingNextPage) {
        fetchNextPage();
      }
    },
    { threshold: 0.1 },
  );

  if (status === 'pending') return <PostSkeleton count={5} />;
  if (status === 'error') return <ErrorState />;

  const posts = data.pages.flatMap((page) => page.posts);

  return (
    <div>
      {posts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}

      {/* Sentinel: invisible element at the bottom */}
      <div ref={sentinelRef} className="h-4" />

      {isFetchingNextPage && <PostSkeleton count={3} />}

      {!hasNextPage && posts.length > 0 && (
        <p className="text-center text-gray-500 py-8">
          You've reached the end
        </p>
      )}
    </div>
  );
}

// Intersection Observer hook
function useIntersectionObserver(
  callback: (isIntersecting: boolean) => void,
  options?: IntersectionObserverInit,
) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const observer = new IntersectionObserver(
      ([entry]) => callback(entry.isIntersecting),
      options,
    );

    observer.observe(element);
    return () => observer.disconnect();
  }, [callback, options]);

  return ref;
}

Prefetching for Instant Navigation

// src/components/PostList.tsx โ€” Prefetch on hover for instant navigation

import { useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';

export function PostListItem({ post }: { post: Post }) {
  const queryClient = useQueryClient();

  // Prefetch post detail on hover โ€” ready before user clicks
  const handleMouseEnter = useCallback(() => {
    queryClient.prefetchQuery({
      queryKey: queryKeys.posts.detail(post.id),
      queryFn: () => api.posts.get(post.id),
      staleTime: 5 * 60 * 1000,
    });
  }, [queryClient, post.id]);

  return (
    <Link
      href={`/posts/${post.id}`}
      onMouseEnter={handleMouseEnter}  // Prefetch on hover
      onFocus={handleMouseEnter}       // Accessibility: prefetch on focus too
    >
      {post.title}
    </Link>
  );
}

Global Query Client Configuration

// src/providers/QueryProvider.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // Don't refetch on window focus in dev (annoying during development)
        refetchOnWindowFocus: process.env.NODE_ENV === 'production',
        // Don't refetch on reconnect for short disconnects
        refetchOnReconnect: 'always',
        // Global stale time: 1 minute (override per query as needed)
        staleTime: 60 * 1000,
        // Keep in cache for 5 minutes after component unmounts
        gcTime: 5 * 60 * 1000,
        // Retry with exponential backoff, max 3 times
        retry: 3,
        retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
      },
      mutations: {
        // Don't retry mutations by default (idempotency concern)
        retry: false,
        onError: (error) => {
          // Global error handler โ€” show toast, log to Sentry
          console.error('Mutation error:', error);
        },
      },
    },
  });
}

export function QueryProvider({ children }: { children: React.ReactNode }) {
  // Use state to ensure each render gets same QueryClient instance
  const [queryClient] = useState(() => makeQueryClient());

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      {process.env.NODE_ENV === 'development' && (
        <ReactQueryDevtools initialIsOpen={false} />
      )}
    </QueryClientProvider>
  );
}

Our Capabilities

We architect React applications with production-grade server-state management โ€” from query key design through cache invalidation, optimistic mutations, and prefetching strategies.

What we deliver:

  • Query key factory design for predictable cache invalidation
  • Mutation patterns with optimistic updates and rollback
  • Infinite scroll implementation with cursor-based pagination
  • Query client configuration for production workloads
  • Migration from Redux/SWR to TanStack Query

โ†’ Discuss your React architecture โ†’ Web development services


reacttanstack-querytypescriptstate-managementweb-dev
Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.