Back to Blog

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.

Viprasol Tech Team
November 8, 2026
14 min read

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

ScenarioRecommendation
Form statereact-hook-form โ€” not React Query
UI state (modal open, selected tab)useState or Zustand
WebSocket real-time dataZustand + WebSocket, or React Query + setQueryData on message
Heavy client-side computationuseMemo + Web Worker
Global app settings (theme, locale)React Context or Zustand

See Also


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.

Talk to our team โ†’ | See our web development services โ†’

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 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.

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.