React Query Patterns in 2026: Query Invalidation, Optimistic Updates, and Infinite Scroll
Master TanStack Query v5 patterns: query key factories, cache invalidation strategies, optimistic mutations, infinite scroll with useInfiniteQuery, prefetching, and server-state architecture.
React Query Patterns in 2026: Query Invalidation, Optimistic Updates, and Infinite Scroll
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),
});
},
});
}

๐ 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
Recommended Reading
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
External Resources
About the Author
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.
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
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.