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 100+ 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
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>
);
}
Working With Viprasol
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
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 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
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.