React Query Server State: useQuery Patterns, Optimistic Updates, and Infinite Scroll
Master TanStack Query v5: useQuery caching patterns, useMutation with optimistic updates, useInfiniteQuery for infinite scroll, prefetching with Next.js App Router, and query invalidation strategies.
Server state is fundamentally different from client state. It lives on the server, it can change at any moment without your app knowing, and fetching it is asynchronous. Redux was never designed for this โ it was designed for UI state. TanStack Query (React Query) treats server state as a first-class citizen: it handles caching, background refetching, deduplication, stale-time, garbage collection, and mutation side effects so you don't have to.
This post covers the v5 API patterns our team reaches for in production: query key factories, mutation pipelines with optimistic updates, infinite scroll, Next.js App Router prefetching, and cache invalidation strategies.
Setup
pnpm add @tanstack/react-query @tanstack/react-query-devtools
// 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: {
staleTime: 60 * 1000, // 1 min: data considered fresh
gcTime: 5 * 60 * 1000, // 5 min: keep in cache after unmount
refetchOnWindowFocus: true,
refetchOnReconnect: true,
retry: (failureCount, error: any) => {
// Don't retry 4xx errors โ they won't succeed
if (error?.status >= 400 && error?.status < 500) return false;
return failureCount < 2;
},
},
mutations: {
retry: false,
},
},
});
}
let browserQueryClient: QueryClient | undefined;
function getQueryClient() {
if (typeof window === 'undefined') {
// Server: always make a new client
return makeQueryClient();
}
// Browser: reuse singleton
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
export function QueryProvider({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
1. Query Key Factories
Query keys are the cache address. Consistent, structured keys prevent cache misses and make invalidation predictable.
// src/lib/query-keys.ts
export const queryKeys = {
// Top-level domain
posts: {
all: ['posts'] as const,
lists: () => [...queryKeys.posts.all, 'list'] as const,
list: (filters: PostFilters) => [...queryKeys.posts.lists(), filters] as const,
details: () => [...queryKeys.posts.all, 'detail'] as const,
detail: (id: string) => [...queryKeys.posts.details(), id] as const,
},
users: {
all: ['users'] as const,
current: () => [...queryKeys.users.all, 'current'] as const,
detail: (id: string) => [...queryKeys.users.all, id] as const,
permissions: (id: string) => [...queryKeys.users.all, id, 'permissions'] as const,
},
billing: {
all: ['billing'] as const,
subscription: (accountId: string) =>
[...queryKeys.billing.all, 'subscription', accountId] as const,
invoices: (accountId: string) =>
[...queryKeys.billing.all, 'invoices', accountId] as const,
invoice: (id: string) => [...queryKeys.billing.all, 'invoice', id] as const,
},
} as const;
// Usage:
// queryClient.invalidateQueries({ queryKey: queryKeys.posts.lists() })
// โ invalidates ALL post lists regardless of filters
๐ 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
2. useQuery: Data Fetching Patterns
Basic Query with Error and Loading States
// src/hooks/usePosts.ts
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '../lib/query-keys';
import { api } from '../lib/api-client';
interface PostFilters {
status?: 'published' | 'draft';
authorId?: string;
page?: number;
limit?: number;
}
interface PostsResponse {
posts: Post[];
total: number;
page: number;
totalPages: number;
}
export function usePosts(filters: PostFilters = {}) {
return useQuery({
queryKey: queryKeys.posts.list(filters),
queryFn: () => api.get<PostsResponse>('/posts', { params: filters }),
staleTime: 30 * 1000, // Posts go stale after 30 seconds
placeholderData: (previousData) => previousData, // Keep previous while loading
});
}
export function usePost(id: string) {
return useQuery({
queryKey: queryKeys.posts.detail(id),
queryFn: () => api.get<Post>(`/posts/${id}`),
enabled: !!id, // Don't run if id is empty
});
}
// src/components/PostList.tsx
'use client';
import { usePosts } from '../hooks/usePosts';
export function PostList({ status }: { status: 'published' | 'draft' }) {
const { data, isLoading, isError, error, isFetching } = usePosts({ status });
if (isLoading) return <PostListSkeleton />;
if (isError) return <ErrorMessage error={error} />;
return (
<div>
{/* isFetching shows background refetch indicator */}
{isFetching && <RefreshIndicator />}
{data?.posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
<Pagination total={data?.totalPages ?? 0} />
</div>
);
}
Dependent Queries
// Fetch user, then fetch their team based on user's teamId
export function useUserWithTeam(userId: string) {
const userQuery = useQuery({
queryKey: queryKeys.users.detail(userId),
queryFn: () => api.get<User>(`/users/${userId}`),
});
const teamQuery = useQuery({
queryKey: ['teams', userQuery.data?.teamId],
queryFn: () => api.get<Team>(`/teams/${userQuery.data!.teamId}`),
enabled: !!userQuery.data?.teamId, // Only run when user data is available
});
return { userQuery, teamQuery };
}
Parallel Queries
// Fetch multiple resources simultaneously
export function useDashboardData(accountId: string) {
const [subscriptionQuery, invoicesQuery, usageQuery] = useQueries({
queries: [
{
queryKey: queryKeys.billing.subscription(accountId),
queryFn: () => api.get<Subscription>(`/billing/subscription`),
},
{
queryKey: queryKeys.billing.invoices(accountId),
queryFn: () => api.get<Invoice[]>(`/billing/invoices`),
},
{
queryKey: ['usage', accountId],
queryFn: () => api.get<UsageStats>(`/usage`),
staleTime: 5 * 60 * 1000, // Usage data: 5 min stale time
},
],
});
return {
subscription: subscriptionQuery,
invoices: invoicesQuery,
usage: usageQuery,
isLoading:
subscriptionQuery.isLoading ||
invoicesQuery.isLoading ||
usageQuery.isLoading,
};
}
3. useMutation with Optimistic Updates
Optimistic updates give instant UI feedback while the server request is in-flight. On error, they roll back.
Optimistic Post Like
// src/hooks/usePostLike.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '../lib/query-keys';
import { api } from '../lib/api-client';
export function useTogglePostLike(postId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (liked: boolean) =>
liked
? api.post(`/posts/${postId}/like`)
: api.delete(`/posts/${postId}/like`),
// Fires BEFORE the mutation function
onMutate: async (liked: boolean) => {
// Cancel any in-flight queries for this post (avoid race condition)
await queryClient.cancelQueries({
queryKey: queryKeys.posts.detail(postId),
});
// Snapshot previous value for rollback
const previousPost = queryClient.getQueryData<Post>(
queryKeys.posts.detail(postId)
);
// Optimistically update the cache
queryClient.setQueryData<Post>(queryKeys.posts.detail(postId), (old) => {
if (!old) return old;
return {
...old,
liked,
likeCount: liked ? old.likeCount + 1 : old.likeCount - 1,
};
});
// Return context for rollback
return { previousPost };
},
// On error: roll back to snapshot
onError: (_err, _liked, context) => {
if (context?.previousPost) {
queryClient.setQueryData(
queryKeys.posts.detail(postId),
context.previousPost
);
}
},
// On success or error: refetch to sync with server
onSettled: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.posts.detail(postId),
});
},
});
}
Optimistic List Item Add/Remove
// src/hooks/useTodoMutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
const TODO_LIST_KEY = ['todos'] as const;
export function useAddTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (text: string) =>
api.post<Todo>('/todos', { text }),
onMutate: async (text: string) => {
await queryClient.cancelQueries({ queryKey: TODO_LIST_KEY });
const previous = queryClient.getQueryData<Todo[]>(TODO_LIST_KEY);
// Add a temporary todo with a fake ID
const tempTodo: Todo = {
id: `temp-${Date.now()}`,
text,
completed: false,
createdAt: new Date().toISOString(),
};
queryClient.setQueryData<Todo[]>(TODO_LIST_KEY, (old = []) => [
tempTodo,
...old,
]);
return { previous, tempTodo };
},
onError: (_err, _text, context) => {
if (context?.previous) {
queryClient.setQueryData(TODO_LIST_KEY, context.previous);
}
},
onSuccess: (newTodo, _text, context) => {
// Replace temp todo with real todo from server
queryClient.setQueryData<Todo[]>(TODO_LIST_KEY, (old = []) =>
old.map((todo) =>
todo.id === context?.tempTodo.id ? newTodo : todo
)
);
},
});
}
๐ 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
4. useInfiniteQuery for Infinite Scroll
// src/hooks/useInfinitePosts.ts
import { useInfiniteQuery } from '@tanstack/react-query';
import { api } from '../lib/api-client';
interface PostsPage {
posts: Post[];
nextCursor: string | null;
hasNextPage: boolean;
}
export function useInfinitePosts(filters: { status?: string } = {}) {
return useInfiniteQuery({
queryKey: ['posts', 'infinite', filters],
queryFn: ({ pageParam }) =>
api.get<PostsPage>('/posts', {
params: { ...filters, cursor: pageParam, limit: 20 },
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.hasNextPage ? lastPage.nextCursor : undefined,
staleTime: 30 * 1000,
});
}
// src/components/InfinitePostFeed.tsx
'use client';
import { useInfinitePosts } from '../hooks/useInfinitePosts';
import { useEffect, useRef } from 'react';
export function InfinitePostFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
} = useInfinitePosts();
// Intersection Observer for automatic load-more
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ rootMargin: '200px' } // Trigger 200px before bottom
);
if (loadMoreRef.current) observer.observe(loadMoreRef.current);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
if (isLoading) return <PostListSkeleton />;
if (isError) return <ErrorMessage />;
// Flatten all pages into a single list
const allPosts = data?.pages.flatMap((page) => page.posts) ?? [];
return (
<div>
{allPosts.map((post) => (
<PostCard key={post.id} post={post} />
))}
{/* Sentinel element โ triggers next page load when visible */}
<div ref={loadMoreRef}>
{isFetchingNextPage ? (
<LoadingSpinner />
) : hasNextPage ? (
<button onClick={() => fetchNextPage()}>Load more</button>
) : (
<p className="text-gray-400 text-center py-8">No more posts</p>
)}
</div>
</div>
);
}
5. Next.js App Router: Server-Side Prefetching
With Next.js App Router, prefetch data on the server so the page hydrates instantly with no loading state.
// src/app/posts/page.tsx โ Server Component
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query';
import { queryKeys } from '../../lib/query-keys';
import { serverApi } from '../../lib/server-api';
import { PostList } from '../../components/PostList';
export default async function PostsPage() {
const queryClient = new QueryClient();
// Prefetch on server โ data available immediately on client
await queryClient.prefetchQuery({
queryKey: queryKeys.posts.list({ status: 'published' }),
queryFn: () => serverApi.get('/posts?status=published'),
});
return (
// Pass dehydrated state to client
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList status="published" />
</HydrationBoundary>
);
}
// src/app/posts/[id]/page.tsx โ Server Component with params
export default async function PostPage({ params }: { params: { id: string } }) {
const queryClient = new QueryClient();
// Prefetch post AND related comments in parallel
await Promise.all([
queryClient.prefetchQuery({
queryKey: queryKeys.posts.detail(params.id),
queryFn: () => serverApi.get(`/posts/${params.id}`),
}),
queryClient.prefetchQuery({
queryKey: ['comments', params.id],
queryFn: () => serverApi.get(`/posts/${params.id}/comments`),
}),
]);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostDetail id={params.id} />
<CommentSection postId={params.id} />
</HydrationBoundary>
);
}
6. Cache Invalidation Strategies
// src/hooks/usePostMutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '../lib/query-keys';
export function usePublishPost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (postId: string) =>
api.post<Post>(`/posts/${postId}/publish`),
onSuccess: (publishedPost) => {
// Strategy 1: Update the specific post in cache
queryClient.setQueryData(
queryKeys.posts.detail(publishedPost.id),
publishedPost
);
// Strategy 2: Invalidate all lists (they may need resorting/refiltering)
queryClient.invalidateQueries({
queryKey: queryKeys.posts.lists(),
});
// Strategy 3: Remove draft lists from cache (they shouldn't show the post)
queryClient.removeQueries({
queryKey: queryKeys.posts.list({ status: 'draft' }),
});
},
});
}
// Global mutation observer: invalidate current user data after any auth mutation
export function useAuthMutationObserver() {
const queryClient = useQueryClient();
useEffect(() => {
const unsubscribe = queryClient.getMutationCache().subscribe((event) => {
if (
event.type === 'updated' &&
event.mutation.state.status === 'success' &&
event.mutation.options.mutationKey?.[0] === 'auth'
) {
queryClient.invalidateQueries({ queryKey: queryKeys.users.current() });
}
});
return unsubscribe;
}, [queryClient]);
}
Prefetch on Hover
// src/components/PostCard.tsx
export function PostCard({ post }: { post: Post }) {
const queryClient = useQueryClient();
const prefetchPost = () => {
queryClient.prefetchQuery({
queryKey: queryKeys.posts.detail(post.id),
queryFn: () => api.get<Post>(`/posts/${post.id}`),
staleTime: 30 * 1000,
});
};
return (
<Link
href={`/posts/${post.id}`}
onMouseEnter={prefetchPost} // Prefetch on hover
onFocus={prefetchPost} // Accessibility: prefetch on focus too
>
<article>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
</Link>
);
}
Error Handling and Retry
// src/lib/api-client.ts
import axios, { AxiosError } from 'axios';
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
message: string
) {
super(message);
this.name = 'ApiError';
}
}
export const api = axios.create({ baseURL: '/api' });
api.interceptors.response.use(
(response) => response.data,
(error: AxiosError<{ error: string; code?: string }>) => {
const status = error.response?.status ?? 500;
const code = error.response?.data?.code ?? 'UNKNOWN';
const message = error.response?.data?.error ?? error.message;
throw new ApiError(status, code, message);
}
);
// React Query global error handler
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
if (error instanceof ApiError && error.status === 401) {
// Redirect to login on auth errors
window.location.href = `/login?returnTo=${window.location.pathname}`;
}
},
}),
});
When NOT to Use React Query
| Scenario | Recommendation |
|---|---|
| Form state | react-hook-form โ not React Query |
| UI state (modal open, selected tab) | useState or Zustand |
| WebSocket real-time data | Zustand + WebSocket, or React Query + setQueryData on message |
| Heavy client-side computation | useMemo + Web Worker |
| Global app settings (theme, locale) | React Context or Zustand |
See Also
- React Hook Form + Zod: Type-Safe Forms and Multi-Step Wizards
- Next.js App Router Patterns: Server Components and Server Actions
- React Suspense Patterns for App Router
- React Performance Optimization: Memo, Virtualization, and Code Splitting
- Next.js Testing Strategy: Unit, Integration, and E2E with Playwright
Working With Viprasol
Building a data-heavy React application and drowning in loading states, stale data, and cache inconsistencies? We architect React Query setups with structured query key factories, optimistic mutation pipelines, and Next.js App Router prefetching that make your UI feel instant โ regardless of network conditions.
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.