Back to Blog

React Query Server State: useQuery Patterns, Optimistic Updates

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
14 min read
Updated 2026

Quick answer. TanStack Query (React Query) treats server state as first-class, handling caching, background refetching, deduplication, stale-time, and garbage collection that Redux was never built for. Production v5 patterns include query key factories, optimistic-update mutation pipelines, infinite scroll, App Router prefetching, and cache invalidation. 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 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

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
        )
      );
    },
  });
}

react - React Query Server State: useQuery Patterns, 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

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

Continue Learning


Our Capabilities

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 โ†’

How the TanStack Query Official Docs Frame Server State in React

When you compare the patterns here to the TanStack Query official docs, server state in React stands apart from local UI state because it lives on a backend you do not fully control. It can be stale the moment you fetch it, mutated by other clients, and shared across components. That distinction drives the whole useQuery model: caching, background refetching, and deduplication exist precisely because remote data needs different handling than form inputs or toggles.

In practice, we lean on query keys for cache identity, staleTime to tune refetch frequency, and the documented invalidation flow to keep optimistic updates honest. Our engineers ship these patterns end to end and own them in production, so async server state, cache consistency, and mutation rollbacks stay predictable as your React app grows.

reacttypescripttanstack-querynext.jsstate-managementfrontend
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.