React Query (TanStack): Data Fetching Patterns

Master TanStack Query v5 data fetching patterns: queries, mutations, optimistic updates, infinite scroll, prefetching, cache invalidation, and integration with Next.js App Router.

E
ECOSIRE Research and Development Team
|March 19, 20269 min read2.1k Words|

Part of our Performance & Scalability series

Read the complete guide

React Query (TanStack): Data Fetching Patterns

Data fetching in React has a surprisingly large surface area: loading states, error states, background refetching, cache invalidation after mutations, optimistic updates, infinite scroll, prefetching, and deduplication. Writing this logic by hand with useEffect + useState leads to race conditions, memory leaks, and inconsistent UI states. TanStack Query (formerly React Query) v5 solves all of this with a clean, composable API.

This guide covers TanStack Query v5 patterns for production React applications — from basic queries through complex mutation flows, optimistic updates, infinite scroll, and Next.js App Router integration. Every pattern includes TypeScript and error handling.

Key Takeaways

  • TanStack Query is a server state library — do not use it for client-only UI state (use Zustand or useState for that)
  • Query keys are the cache address — include all variables that affect the query result
  • staleTime controls when background refetching triggers; gcTime controls when unused data is garbage collected
  • Mutations should invalidate related queries via queryClient.invalidateQueries() in onSuccess
  • Optimistic updates improve perceived performance but require rollback logic in onError
  • Use useInfiniteQuery for paginated lists; never combine useQuery + manual page state
  • Prefetch on hover for instant navigation feel: queryClient.prefetchQuery()
  • In Next.js App Router, use HydrationBoundary to dehydrate server-fetched data into the client

Setup and Configuration

pnpm add @tanstack/react-query @tanstack/react-query-devtools
// src/providers/query-provider.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';

export function QueryProvider({ children }: { children: React.ReactNode }) {
  // Create QueryClient inside component to avoid shared state between requests (SSR safety)
  const [queryClient] = useState(() =>
    new QueryClient({
      defaultOptions: {
        queries: {
          staleTime:            60 * 1000, // 1 minute — data is fresh for 1 minute
          gcTime:           5 * 60 * 1000, // 5 minutes — keep unused cache for 5 minutes
          retry:                        2, // Retry failed requests twice
          retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000), // Exponential backoff
          refetchOnWindowFocus:      true, // Refetch when user returns to tab
          refetchOnReconnect:        true, // Refetch after network reconnect
        },
        mutations: {
          retry: 0, // Do not retry mutations (non-idempotent)
        },
      },
    })
  );

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}
// src/app/[locale]/layout.tsx
import { QueryProvider } from '@/providers/query-provider';

export default function LocaleLayout({ children }: { children: React.ReactNode }) {
  return (
    <QueryProvider>
      {children}
    </QueryProvider>
  );
}

Query Keys — The Foundation

Query keys are the cache address for your data. Every variable that affects the query result must be in the key.

// src/lib/query-keys.ts
// Centralize query keys to avoid typos and enable easy invalidation
export const queryKeys = {
  contacts: {
    all:          () => ['contacts'] as const,
    lists:        () => [...queryKeys.contacts.all(), 'list'] as const,
    list:         (filters: ContactFilters) =>
                    [...queryKeys.contacts.lists(), filters] as const,
    details:      () => [...queryKeys.contacts.all(), 'detail'] as const,
    detail:       (id: string) =>
                    [...queryKeys.contacts.details(), id] as const,
  },
  orders: {
    all:    () => ['orders'] as const,
    list:   (orgId: string, page: number) => ['orders', orgId, page] as const,
    detail: (id: string) => ['orders', id] as const,
  },
} as const;

// Usage in queries and invalidations:
// useQuery({ queryKey: queryKeys.contacts.list({ page: 1, limit: 20 }) })
// queryClient.invalidateQueries({ queryKey: queryKeys.contacts.lists() })
// — invalidates ALL list queries regardless of filter parameters

Basic Queries

// src/hooks/use-contacts.ts
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api';
import { queryKeys } from '@/lib/query-keys';

interface ContactFilters {
  page: number;
  limit: number;
  search?: string;
  status?: string;
}

interface ContactsResponse {
  data: Contact[];
  total: number;
  page: number;
  limit: number;
}

export function useContacts(filters: ContactFilters) {
  return useQuery({
    queryKey: queryKeys.contacts.list(filters),
    queryFn: async () => {
      const params = new URLSearchParams({
        page: String(filters.page),
        limit: String(filters.limit),
        ...(filters.search && { search: filters.search }),
        ...(filters.status && { status: filters.status }),
      });
      return apiFetch<ContactsResponse>(`/contacts?${params}`);
    },
    placeholderData: (previousData) => previousData, // Keep previous page data while fetching
    staleTime: 30_000, // Contacts data: 30 second stale time
  });
}

export function useContact(id: string) {
  return useQuery({
    queryKey: queryKeys.contacts.detail(id),
    queryFn: () => apiFetch<Contact>(`/contacts/${id}`),
    enabled: !!id, // Only fetch if id is provided
  });
}
// Usage in a component
'use client';

export default function ContactsPage() {
  const [page, setPage] = useState(1);
  const [search, setSearch] = useState('');

  const { data, isLoading, isError, error, isFetching } = useContacts({
    page,
    limit: 20,
    search: search || undefined,
  });

  if (isLoading) return <ContactsSkeleton />;
  if (isError) return <ErrorState message={error.message} />;

  return (
    <div>
      {isFetching && <RefetchIndicator />} {/* Shows during background refetch */}
      <ContactsTable data={data.data} />
      <Pagination
        page={page}
        total={data.total}
        limit={20}
        onChange={setPage}
      />
    </div>
  );
}

Mutations with Cache Invalidation

// src/hooks/use-contact-mutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api';
import { queryKeys } from '@/lib/query-keys';
import { toast } from '@/components/ui/toast';

export function useCreateContact() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: CreateContactDto) =>
      apiFetch<Contact>('/contacts', {
        method: 'POST',
        body: JSON.stringify(data),
      }),

    onSuccess: (newContact) => {
      // Invalidate all contact list queries — they are now stale
      queryClient.invalidateQueries({
        queryKey: queryKeys.contacts.lists(),
      });

      // Seed the detail cache immediately — no need to refetch
      queryClient.setQueryData(
        queryKeys.contacts.detail(newContact.id),
        newContact
      );

      toast({ title: 'Contact created', variant: 'success' });
    },

    onError: (error: Error) => {
      toast({ title: 'Failed to create contact', description: error.message, variant: 'destructive' });
    },
  });
}

export function useDeleteContact() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (id: string) =>
      apiFetch(`/contacts/${id}`, { method: 'DELETE' }),

    onSuccess: (_data, deletedId) => {
      // Remove from all list queries
      queryClient.invalidateQueries({ queryKey: queryKeys.contacts.lists() });
      // Remove detail cache
      queryClient.removeQueries({ queryKey: queryKeys.contacts.detail(deletedId) });
      toast({ title: 'Contact deleted', variant: 'success' });
    },
  });
}

Optimistic Updates

Optimistic updates make mutations feel instant by updating the UI before the server confirms:

export function useUpdateContact() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: UpdateContactDto }) =>
      apiFetch<Contact>(`/contacts/${id}`, {
        method: 'PATCH',
        body: JSON.stringify(data),
      }),

    // Called immediately before mutationFn
    onMutate: async ({ id, data }) => {
      // Cancel any in-flight refetches for this contact
      await queryClient.cancelQueries({ queryKey: queryKeys.contacts.detail(id) });

      // Snapshot the previous value for rollback
      const previousContact = queryClient.getQueryData<Contact>(
        queryKeys.contacts.detail(id)
      );

      // Optimistically update the UI
      queryClient.setQueryData<Contact>(
        queryKeys.contacts.detail(id),
        (old) => old ? { ...old, ...data } : old
      );

      return { previousContact }; // Passed to onError context
    },

    onError: (_error, { id }, context) => {
      // Roll back to the snapshot on error
      if (context?.previousContact) {
        queryClient.setQueryData(
          queryKeys.contacts.detail(id),
          context.previousContact
        );
      }
      toast({ title: 'Update failed', variant: 'destructive' });
    },

    onSettled: (_data, _error, { id }) => {
      // Always refetch after success or error to sync with server truth
      queryClient.invalidateQueries({ queryKey: queryKeys.contacts.detail(id) });
    },
  });
}

Infinite Scroll / Pagination

// src/hooks/use-infinite-contacts.ts
import { useInfiniteQuery } from '@tanstack/react-query';

export function useInfiniteContacts(search?: string) {
  return useInfiniteQuery({
    queryKey: ['contacts', 'infinite', search],
    queryFn: ({ pageParam = 1 }) =>
      apiFetch<ContactsResponse>(`/contacts?page=${pageParam}&limit=20${search ? `&search=${search}` : ''}`),

    initialPageParam: 1,
    getNextPageParam: (lastPage) => {
      const nextPage = lastPage.page + 1;
      const maxPage = Math.ceil(lastPage.total / lastPage.limit);
      return nextPage <= maxPage ? nextPage : undefined;
    },

    staleTime: 30_000,
  });
}
// Infinite scroll component
'use client';

import { useInfiniteContacts } from '@/hooks/use-infinite-contacts';
import { useInView } from 'react-intersection-observer';
import { useEffect } from 'react';

export function InfiniteContactsList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteContacts();

  const { ref, inView } = useInView({ threshold: 0.5 });

  // Fetch next page when the sentinel element enters the viewport
  useEffect(() => {
    if (inView && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);

  if (isLoading) return <ContactsSkeleton />;

  const contacts = data?.pages.flatMap((page) => page.data) ?? [];

  return (
    <div>
      {contacts.map((contact) => (
        <ContactCard key={contact.id} contact={contact} />
      ))}

      {/* Sentinel element — triggers next page load when visible */}
      <div ref={ref} className="h-4">
        {isFetchingNextPage && <LoadingSpinner />}
      </div>
    </div>
  );
}

Prefetching for Instant Navigation

// Prefetch on hover — data is ready before the user clicks
'use client';

import { useQueryClient } from '@tanstack/react-query';
import { Link } from '@/i18n/navigation';
import { queryKeys } from '@/lib/query-keys';
import { apiFetch } from '@/lib/api';

export function ContactRow({ contact }: { contact: Contact }) {
  const queryClient = useQueryClient();

  const handleMouseEnter = () => {
    queryClient.prefetchQuery({
      queryKey: queryKeys.contacts.detail(contact.id),
      queryFn: () => apiFetch<Contact>(`/contacts/${contact.id}`),
      staleTime: 60_000, // Use cached if less than 1 minute old
    });
  };

  return (
    <tr onMouseEnter={handleMouseEnter}>
      <td>
        <Link href={`/dashboard/contacts/${contact.id}`}>{contact.name}</Link>
      </td>
      <td>{contact.email}</td>
    </tr>
  );
}

Next.js App Router SSR + TanStack Query

// src/app/[locale]/dashboard/contacts/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';
import { getServerContacts } from '@/lib/server-api';
import { ContactsClient } from './contacts-client';

export default async function ContactsPage({ params }: Props) {
  const { locale } = await params;
  const queryClient = new QueryClient();

  // Prefetch on the server — data is embedded in the HTML
  await queryClient.prefetchQuery({
    queryKey: queryKeys.contacts.list({ page: 1, limit: 20 }),
    queryFn: () => getServerContacts({ page: 1, limit: 20 }),
  });

  return (
    // HydrationBoundary serializes the server QueryClient state
    // into the HTML, which the client QueryClient then picks up
    <HydrationBoundary state={dehydrate(queryClient)}>
      <ContactsClient />
    </HydrationBoundary>
  );
}
// src/app/[locale]/dashboard/contacts/contacts-client.tsx ('use client')
'use client';

import { useContacts } from '@/hooks/use-contacts';

export function ContactsClient() {
  const { data } = useContacts({ page: 1, limit: 20 });
  // data is immediately available from the server-dehydrated state
  // No loading state on first render — SSR data is used instantly

  return <ContactsTable data={data?.data ?? []} />;
}

Frequently Asked Questions

When should I use TanStack Query vs SWR vs a plain fetch?

Use TanStack Query when you need: cache invalidation after mutations, optimistic updates, infinite scroll, prefetching, background refetching, or request deduplication. Use SWR if you need a smaller bundle and only simple use cases. Use plain fetch in Next.js Server Components where you want React's native data caching (the cache() function and revalidatePath() pattern). TanStack Query is for client-side interactivity where user actions affect server data.

What is the difference between staleTime and gcTime?

staleTime is how long data is considered fresh — during this time, no background refetch happens and the cached data is returned immediately. After staleTime expires, data is "stale" and will refetch in the background on next use. gcTime (previously cacheTime) is how long unused (no active subscribers) data stays in memory before garbage collection. Set staleTime based on how quickly your data changes; set gcTime based on how long you want navigating away and back to feel instant.

How do I handle authentication errors (401) globally?

Add a global error handler in your QueryClient default options. On 401, redirect to login and clear the query cache. In TanStack Query v5: defaultOptions: { queries: { throwOnError: (error) => { if (error.status === 401) { router.push('/auth/login'); queryClient.clear(); return false; } return true; } } }. Alternatively, handle 401 in your apiFetch helper by calling a logout() function.

How do I share query state between sibling components without prop drilling?

Any component that calls useQuery with the same query key shares the same cached data — TanStack Query deduplicates requests automatically. Two ContactsTable components and a ContactCount badge calling useContacts({ page: 1, limit: 20 }) with the same filters will all read from a single cached request. No prop drilling, no context needed.

Should query keys include the user's session or organization ID?

Yes — if data is scoped to a user or organization, include the scoping identifier in the query key. This prevents one user's data from appearing for another (critical for multi-tenant apps). Also clear the entire query cache on logout: queryClient.clear() or queryClient.removeQueries() — otherwise stale data from the previous session may flash briefly for the next user.


Next Steps

TanStack Query transforms React data fetching from a state management problem into a declarative, cache-aware system. Optimistic updates, prefetching, and SSR hydration give your users an experience that feels instant — even over slow connections.

ECOSIRE builds Next.js admin panels and customer portals with TanStack Query for all server state, combined with Zustand for client-only UI state and TanStack Table for data-heavy views. Explore our frontend engineering services to learn how we build performant, production-ready React applications.

E

Written by

ECOSIRE Research and Development Team

Building enterprise-grade digital products at ECOSIRE. Sharing insights on Odoo integrations, e-commerce automation, and AI-powered business solutions.

Chat on WhatsApp