Skip to content

Data Fetching with TanStack Query — React Query v5

DodaTech 8 min read

In this tutorial, you'll learn about Data Fetching with TanStack Query. We cover key concepts, practical examples, and best practices.

What You'll Learn

Manage server state in React with TanStack Query (React Query v5) — caching, background refetching, pagination, infinite queries, optimistic updates, and mutation with TypeScript.

Why It Matters

Most React apps spend 60% of code on data fetching, caching, and state synchronization. TanStack Query eliminates boilerplate by providing automatic caching, background refetching, deduplication, and stale-while-revalidate — patterns that are tedious to implement manually with useEffect.

Real-World Use

A real-time dashboard that fetches stock prices every 30 seconds, automatically caches responses, refetches on window focus, paginates through trade history, and optimistically updates the UI when the user places a trade order.

Learning Path

flowchart LR
    A[React Basics] --> B[API Integration]
    B --> C[TanStack Query]
    C --> D[State Management]
    D --> E[Performance Optimization]
    E --> F[Final Project]
    C -->|You are here| C

Setting Up QueryClient and Provider

TanStack Query manages server state outside of React's component tree. The QueryClientProvider gives access to all query hooks.

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

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,   // 5 minutes
      gcTime: 1000 * 60 * 30,      // 30 minutes (formerly cacheTime)
      retry: 2,
      refetchOnWindowFocus: true,
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Dashboard />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Expected output: The app renders with TanStack Query enabled. The DevTools icon appears in the bottom corner — clicking it shows cached queries, their status, and timestamps. Data stays fresh for 5 minutes and garbage-collected after 30 minutes of inactivity.

Basic Queries with Loading and Error States

The useQuery hook fetches, caches, and synchronizes data.

import { useQuery } from "@tanstack/react-query";

interface Repository {
  id: number;
  name: string;
  description: string;
  stargazers_count: number;
}

async function fetchRepos(): Promise<Repository[]> {
  const res = await fetch("https://api.github.com/orgs/reactjs/repos");
  if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
  return res.json();
}

function RepoList() {
  const { data, isLoading, error, refetch } = useQuery<Repository[]>({
    queryKey: ["repos", "reactjs"],
    queryFn: fetchRepos,
    staleTime: 1000 * 60 * 2, // 2 minutes
  });

  if (isLoading) return <div aria-label="Loading">Loading repositories...</div>;
  if (error) return (
    <div role="alert">
      Error: {(error as Error).message}
      <button onClick={() => refetch()}>Retry</button>
    </div>
  );

  return (
    <ul>
      {data?.map((repo) => (
        <li key={repo.id}>
          <strong>{repo.name}</strong> — {repo.description}
          <span> Stars: {repo.stargazers_count}</span>
        </li>
      ))}
    </ul>
  );
}

Expected output: Initially shows "Loading repositories..." then renders the list of React organization repos with star counts. If the network fails, shows the error with a Retry button. The refetch function allows manual retry. Navigating away and back shows cached data instantly with a background refetch.

Mutations and Optimistic Updates

Mutations handle create, update, and delete operations. Optimistic updates update the UI before the server confirms.

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

async function fetchTodos(): Promise<Todo[]> {
  const res = await fetch("https://jsonplaceholder.typicode.com/todos?_limit=10");
  return res.json();
}

async function addTodo(newTodo: Omit<Todo, "id">): Promise<Todo> {
  const res = await fetch("https://jsonplaceholder.typicode.com/todos", {
    method: "POST",
    body: JSON.stringify(newTodo),
    headers: { "Content-Type": "application/json" },
  });
  return res.json();
}

function TodoList() {
  const queryClient = useQueryClient();

  const { data: todos } = useQuery<Todo[]>({
    queryKey: ["todos"],
    queryFn: fetchTodos,
  });

  const mutation = useMutation({
    mutationFn: addTodo,
    onMutate: async (newTodo) => {
      await queryClient.cancelQueries({ queryKey: ["todos"] });
      const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]);
      queryClient.setQueryData<Todo[]>(["todos"], (old) => [
        ...(old || []),
        { ...newTodo, id: Date.now() },
      ]);
      return { previousTodos };
    },
    onError: (_err, _newTodo, context) => {
      // Rollback on error
      if (context?.previousTodos) {
        queryClient.setQueryData(["todos"], context.previousTodos);
      }
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const form = e.target as HTMLFormElement;
    const title = new FormData(form).get("title") as string;
    if (!title.trim()) return;
    mutation.mutate({ title, completed: false });
    form.reset();
  }

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input name="title" placeholder="Add a todo..." />
        <button type="submit" disabled={mutation.isPending}>
          {mutation.isPending ? "Adding..." : "Add"}
        </button>
      </form>

      <ul>
        {todos?.map((todo) => (
          <li key={todo.id}>
            <span style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
              {todo.title}
            </span>
          </li>
        ))}
      </ul>

      {mutation.isError && (
        <p role="alert">Failed to add todo: {(mutation.error as Error).message}</p>
      )}
    </div>
  );
}

Expected output: Adding a todo immediately shows it in the list (optimistic update). If the server rejects the request, the todo disappears (rollback). The "Add" button shows "Adding..." during submission. The invalidateQueries call refetches the full list after the mutation settles for consistency.

Pagination and Infinite Queries

TanStack Query supports both offset-based pagination and cursor-based infinite queries.

import { useQuery } from "@tanstack/react-query";
import { useState } from "react";

interface PaginatedResponse<T> {
  data: T[];
  total: number;
  page: number;
  pageSize: number;
}

interface Product {
  id: number;
  title: string;
  price: number;
}

async function fetchProducts(page: number): Promise<PaginatedResponse<Product>> {
  const res = await fetch(`https://api.example.com/products?page=${page}&pageSize=10`);
  return res.json();
}

function ProductList() {
  const [page, setPage] = useState(1);

  const { data, isLoading, isFetching } = useQuery({
    queryKey: ["products", page],
    queryFn: () => fetchProducts(page),
    placeholderData: (previousData) => previousData, // Keep previous data while fetching
  });

  const totalPages = data ? Math.ceil(data.total / data.pageSize) : 0;

  return (
    <div>
      {isLoading && <div>Loading products...</div>}

      <ul>
        {data?.data.map((product) => (
          <li key={product.id}>
            {product.title} — ${product.price}
          </li>
        ))}
      </ul>

      {isFetching && !isLoading && <div>Refreshing...</div>}

      <div style={{ display: "flex", gap: "8px" }}>
        <button
          onClick={() => setPage((p) => Math.max(1, p - 1))}
          disabled={page === 1}
        >
          Previous
        </button>
        <span>Page {page} of {totalPages}</span>
        <button
          onClick={() => setPage((p) => p + 1)}
          disabled={page >= totalPages}
        >
          Next
        </button>
      </div>
    </div>
  );
}

Expected output: Page 1 loads initially. Clicking Next loads page 2 while keeping page 1 data visible (via placeholderData). The "Refreshing..." indicator appears during page transitions. The previous data stays visible so the layout does not jump.

Infinite Scrolling

import { useInfiniteQuery } from "@tanstack/react-query";
import { useRef, useCallback } from "react";

interface InfiniteResponse<T> {
  data: T[];
  nextCursor?: number;
}

async function fetchInfinitePosts({ pageParam = 0 }): Promise<InfiniteResponse<Post>> {
  const res = await fetch(`/api/posts?cursor=${pageParam}&limit=20`);
  return res.json();
}

function InfinitePostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteQuery({
    queryKey: ["posts", "infinite"],
    queryFn: fetchInfinitePosts,
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  });

  const observerRef = useRef<IntersectionObserver | null>(null);
  const lastPostRef = useCallback(
    (node: HTMLDivElement | null) => {
      if (isFetchingNextPage) return;
      if (observerRef.current) observerRef.current.disconnect();
      observerRef.current = new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting && hasNextPage) {
          fetchNextPage();
        }
      });
      if (node) observerRef.current.observe(node);
    },
    [isFetchingNextPage, hasNextPage, fetchNextPage]
  );

  if (isLoading) return <div>Loading...</div>;

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

  return (
    <div>
      {posts.map((post, index) => (
        <div
          key={post.id}
          ref={index === posts.length - 1 ? lastPostRef : null}
        >
          <h3>{post.title}</h3>
          <p>{post.body}</p>
        </div>
      ))}
      {isFetchingNextPage && <div>Loading more...</div>}
    </div>
  );
}

Expected output: Initial 20 posts load. Scrolling to the bottom triggers the intersection observer, which calls fetchNextPage. New posts append to the list. "Loading more..." appears during fetch. Cursor-based pagination continues until nextCursor is undefined.

Query Invalidation and Refetching

Keep data fresh by invalidating queries when related data changes.

import { useQueryClient } from "@tanstack/react-query";

function AdminPanel() {
  const queryClient = useQueryClient();

  async function handleClearCache() {
    // Invalidate all queries
    await queryClient.invalidateQueries();
  }

  async function handleRefreshUsers() {
    // Invalidate only user queries
    await queryClient.invalidateQueries({ queryKey: ["users"] });
  }

  async function handleRefetchActive() {
    // Refetch all active queries
    await queryClient.refetchQueries({ type: "active" });
  }

  return (
    <div>
      <button onClick={handleClearCache}>Clear All Cache</button>
      <button onClick={handleRefreshUsers}>Refresh Users</button>
      <button onClick={handleRefetchActive}>Refetch Active</button>
    </div>
  );
}

Common Mistakes

1. Not Setting staleTime

The default staleTime is 0 — every component mount refetches. Set staleTime to match how frequently your data changes.

2. Using enabled: false Without Manual Refetch

Queries with enabled: false do not fetch automatically. You must call refetch() or a dependency must change.

3. Mutating Cache Directly Without Optimistic Updates

Setting query data with setQueryData outside of onMutate is fragile. Always use optimistic update patterns with rollback.

Practice Questions

What is the difference between staleTime and gcTime?

staleTime controls how long data is considered fresh (no refetch needed). gcTime controls how long inactive data stays in the cache before garbage collection. Data can be stale but still cached (returns immediately with background refetch).

How does TanStack Query prevent duplicate requests?

Query deduplication uses the query key as an identifier. If two components mount simultaneously with the same query key, only one request fires. Both components receive the same data when it resolves.

When should I use useInfiniteQuery instead of useQuery with pagination?

Use useInfiniteQuery when data loads incrementally as the user scrolls (infinite scroll) or when you need to load the next page based on a cursor. Use useQuery with manual page state for traditional prev/next pagination.

Challenge

Build a GitHub repository browser that: (1) shows repos for a searched organization with useQuery, (2) allows bookmarking repos with a mutation and optimistic update, (3) implements infinite scroll for issues within a repo using useInfiniteQuery, and (4) invalidates the repo list after adding a new repo.

Real-World Task

Add TanStack Query to an existing React app that uses useState/useEffect for data fetching. Replace all fetch calls with useQuery. Add a mutation with optimistic update for any create action. Configure staleTime and gcTime based on how often each data type changes. Remove all manual loading and error state management.


This tutorial is built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro — production applications where TanStack Query manages thousands of API requests with intelligent caching and real-time updates.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro