Type something to search...
Managing Server State with TanStack Query

Managing Server State with TanStack Query

Fetching data in React looks simple until you ship it. A useEffect with fetch and two pieces of state works for a demo, then the real questions arrive. What happens when the user navigates away mid-request? How do two components share the same response without fetching twice? When should the data be refreshed? After saving a form, which lists need updating? Every app that answers these by hand ends up with a homemade, half-finished cache.

That data has different properties from the rest of your state. It lives on a server, other people can change it, and your copy is only a snapshot that goes stale. This is server state, and TanStack Query (formerly React Query) is a library built specifically to manage it. It caches responses by key, deduplicates requests, refetches in the background, and gives you a clean way to update the cache after mutations.

This guide covers TanStack Query v5: setup, query keys, the stale and garbage collection timers, dependent and parallel queries, mutations with invalidation, optimistic updates, pagination, infinite queries, prefetching, and the mistakes that cause stale or duplicated data.

Server State vs Client State

It helps to separate two kinds of state before writing any code:

  • Client state is owned by the browser: whether a modal is open, the current theme, a form draft. It is always up to date because you are the only one changing it.
  • Server state is owned by a remote system: users, orders, comments. Your copy can be out of date at any moment, and fetching it is asynchronous and can fail.

Tools like useState, Context, or Zustand are great for client state. TanStack Query is for server state. Mixing the two, for example storing API responses in a global store and refreshing them manually, is where many apps accumulate bugs.

Installation and Setup

npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools

Create a QueryClient once and provide it at the root of your app:

// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import App from "./App";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
    },
  },
});

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  </StrictMode>,
);

The client is created outside the component so it is not recreated on every render. The DevTools panel shows every query, its key, status, and cached data, and it is excluded from production builds automatically.

Your First Query

useQuery takes an options object with two required fields: a queryKey and a queryFn.

// src/features/todos/TodoList.tsx
import { useQuery } from "@tanstack/react-query";

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

async function fetchTodos(): Promise<Todo[]> {
  const res = await fetch("/api/todos");
  if (!res.ok) throw new Error(`Request failed: ${res.status}`);
  return res.json();
}

export function TodoList() {
  const { data, isPending, isError, error, isFetching } = useQuery({
    queryKey: ["todos"],
    queryFn: fetchTodos,
  });

  if (isPending) return <p>Loading todos...</p>;
  if (isError) return <p role="alert">Error: {error.message}</p>;

  return (
    <>
      {isFetching && <small>Refreshing...</small>}
      <ul>
        {data.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </>
  );
}

A few important behaviors are already in place:

  • The queryFn must throw on failure. fetch does not reject on HTTP 404 or 500, so check res.ok yourself. Otherwise TanStack Query sees a successful response and caches an error body as data.
  • isPending means there is no data yet. isFetching means a request is running, including background refetches. After the isPending and isError checks, TypeScript knows data is defined.
  • Failed queries retry three times by default with exponential backoff before reporting an error.
  • Every component using ["todos"] shares one cache entry and one request.

Query Keys

The query key identifies the data in the cache. It is an array, and it should include every variable the query function depends on:

function useTodo(id: number) {
  return useQuery({
    queryKey: ["todos", id],
    queryFn: async (): Promise<Todo> => {
      const res = await fetch(`/api/todos/${id}`);
      if (!res.ok) throw new Error("Failed to load todo");
      return res.json();
    },
  });
}

function useFilteredTodos(filters: { status: string; page: number }) {
  return useQuery({
    queryKey: ["todos", "list", filters],
    queryFn: async (): Promise<Todo[]> => {
      const params = new URLSearchParams({
        status: filters.status,
        page: String(filters.page),
      });
      const res = await fetch(`/api/todos?${params}`);
      if (!res.ok) throw new Error("Failed to load todos");
      return res.json();
    },
  });
}

When id or filters change, the key changes and TanStack Query fetches the new entry. Keys are hashed deterministically, so { status, page } and { page, status } produce the same key.

Structure keys from general to specific. That way, invalidating ["todos"] matches ["todos", 5] and ["todos", "list", ...] too, because invalidation matches by prefix.

Sharing Query Definitions With queryOptions

As an app grows, the same key and function get used in several places: components, prefetching, and cache updates. The queryOptions helper keeps them together and preserves types:

// src/features/todos/queries.ts
import { queryOptions } from "@tanstack/react-query";

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

export const todoQueries = {
  all: () => ["todos"] as const,
  list: () =>
    queryOptions({
      queryKey: [...todoQueries.all(), "list"],
      queryFn: async (): Promise<Todo[]> => {
        const res = await fetch("/api/todos");
        if (!res.ok) throw new Error("Failed to load todos");
        return res.json();
      },
    }),
  detail: (id: number) =>
    queryOptions({
      queryKey: [...todoQueries.all(), "detail", id],
      queryFn: async (): Promise<Todo> => {
        const res = await fetch(`/api/todos/${id}`);
        if (!res.ok) throw new Error("Failed to load todo");
        return res.json();
      },
      staleTime: 60_000,
    }),
};

Components then call useQuery(todoQueries.detail(id)), and functions like queryClient.getQueryData(todoQueries.list().queryKey) return correctly typed data.

staleTime and gcTime

Two timers control most of TanStack Query's caching behavior, and they are the most commonly misunderstood options.

staleTime is how long data is considered fresh. While fresh, it is served from the cache without any refetch. The default is 0, meaning data is stale immediately, and stale data is refetched in the background when:

  • a new component using the query mounts,
  • the window regains focus,
  • the network reconnects.

gcTime (called cacheTime before v5) is how long unused data stays in memory after the last component using it unmounts. The default is five minutes.

So with defaults, navigating back to a list shows cached data instantly, then quietly refetches it. If your data rarely changes, raise staleTime to avoid unnecessary requests. A global default of 30 to 60 seconds is a reasonable starting point for many apps, with per-query overrides for data that is more or less volatile.

Dependent and Conditional Queries

Use enabled to hold a query until its inputs are ready:

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

interface User {
  id: number;
  teamId: number;
}

interface Project {
  id: number;
  name: string;
}

export function useTeamProjects(userId: number | undefined) {
  const userQuery = useQuery({
    queryKey: ["users", userId],
    queryFn: async (): Promise<User> => {
      const res = await fetch(`/api/users/${userId}`);
      if (!res.ok) throw new Error("Failed to load user");
      return res.json();
    },
    enabled: userId !== undefined,
  });

  const teamId = userQuery.data?.teamId;

  return useQuery({
    queryKey: ["teams", teamId, "projects"],
    queryFn: async (): Promise<Project[]> => {
      const res = await fetch(`/api/teams/${teamId}/projects`);
      if (!res.ok) throw new Error("Failed to load projects");
      return res.json();
    },
    enabled: teamId !== undefined,
  });
}

Dependent queries are sequential by nature, so use them only when the second request really needs the first result. Independent queries in the same component run in parallel automatically. For a dynamic number of parallel queries, use useQueries.

Mutations and Invalidation

Creating, updating, and deleting data uses useMutation. The key step is telling TanStack Query which cached data is now outdated, which you do with invalidateQueries:

// src/features/todos/AddTodo.tsx
import { useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { todoQueries, type Todo } from "./queries";

async function createTodo(title: string): Promise<Todo> {
  const res = await fetch("/api/todos", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title, completed: false }),
  });
  if (!res.ok) throw new Error("Could not create todo");
  return res.json();
}

export function AddTodo() {
  const [title, setTitle] = useState("");
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: createTodo,
    onSuccess: () => {
      setTitle("");
      return queryClient.invalidateQueries({ queryKey: todoQueries.all() });
    },
  });

  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    mutation.mutate(title);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        aria-label="New todo"
      />
      <button type="submit" disabled={mutation.isPending || !title}>
        {mutation.isPending ? "Adding..." : "Add"}
      </button>
      {mutation.isError && <p role="alert">{mutation.error.message}</p>}
    </form>
  );
}

invalidateQueries marks every matching query as stale and refetches the ones currently on screen. Returning its promise from onSuccess keeps the mutation in the pending state until the refetch completes, so the button does not re-enable before the list updates.

If the server returns the full updated object, you can write it straight into the cache instead of refetching:

onSuccess: (updated: Todo) => {
  queryClient.setQueryData(todoQueries.detail(updated.id).queryKey, updated);
},

Optimistic Updates

For fast interactions like toggling a checkbox, you can update the cache before the server responds and roll back on failure. In v5, onMutate can return a context value that onError receives:

// src/features/todos/useToggleTodo.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { todoQueries, type Todo } from "./queries";

export function useToggleTodo() {
  const queryClient = useQueryClient();
  const listKey = todoQueries.list().queryKey;

  return useMutation({
    mutationFn: async (todo: Todo) => {
      const res = await fetch(`/api/todos/${todo.id}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ completed: !todo.completed }),
      });
      if (!res.ok) throw new Error("Update failed");
      return (await res.json()) as Todo;
    },
    onMutate: async (todo) => {
      await queryClient.cancelQueries({ queryKey: listKey });
      const previous = queryClient.getQueryData(listKey);

      queryClient.setQueryData(listKey, (old) =>
        old?.map((t) =>
          t.id === todo.id ? { ...t, completed: !t.completed } : t,
        ),
      );

      return { previous };
    },
    onError: (_error, _todo, context) => {
      if (context?.previous) {
        queryClient.setQueryData(listKey, context.previous);
      }
    },
    onSettled: () => queryClient.invalidateQueries({ queryKey: listKey }),
  });
}

The steps are always the same:

  1. Cancel in-flight refetches so they do not overwrite the optimistic value.
  2. Snapshot the current cache value.
  3. Write the optimistic value.
  4. Restore the snapshot on error.
  5. Invalidate when settled to sync with the server's truth.

Because the key came from queryOptions, setQueryData knows the cached type is Todo[] without any manual generics. For a simpler alternative when only one component shows the pending change, v5 also lets you render mutation.variables while isPending is true, without touching the cache at all.

Pagination

When a query key includes a page number, each page is a separate cache entry. By default, changing pages shows a loading state while the new page fetches. To keep showing the previous page instead, use placeholderData with keepPreviousData:

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

interface Page {
  items: { id: number; title: string }[];
  hasMore: boolean;
}

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

  const { data, isPending, isPlaceholderData } = useQuery({
    queryKey: ["posts", { page }],
    queryFn: async (): Promise<Page> => {
      const res = await fetch(`/api/posts?page=${page}`);
      if (!res.ok) throw new Error("Failed to load posts");
      return res.json();
    },
    placeholderData: keepPreviousData,
  });

  if (isPending) return <p>Loading...</p>;

  return (
    <div style={{ opacity: isPlaceholderData ? 0.6 : 1 }}>
      <ul>
        {data?.items.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
      <button onClick={() => setPage((p) => p - 1)} disabled={page === 1}>
        Previous
      </button>
      <button
        onClick={() => setPage((p) => p + 1)}
        disabled={isPlaceholderData || !data?.hasMore}
      >
        Next
      </button>
    </div>
  );
}

keepPreviousData replaced the old keepPreviousData: true option in v5. More approaches, including cursor-based APIs, are covered in pagination strategies for React applications.

Infinite Queries

For "load more" lists and infinite scroll, useInfiniteQuery stores all fetched pages together. In v5, initialPageParam is required:

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

interface FeedPage {
  items: { id: number; text: string }[];
  nextCursor: number | null;
}

export function Feed() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } =
    useInfiniteQuery({
      queryKey: ["feed"],
      queryFn: async ({ pageParam }): Promise<FeedPage> => {
        const res = await fetch(`/api/feed?cursor=${pageParam}`);
        if (!res.ok) throw new Error("Failed to load feed");
        return res.json();
      },
      initialPageParam: 0,
      getNextPageParam: (lastPage) => lastPage.nextCursor,
    });

  if (status === "pending") return <p>Loading...</p>;
  if (status === "error") return <p>Could not load the feed.</p>;

  return (
    <>
      {data.pages.flatMap((page) =>
        page.items.map((item) => <p key={item.id}>{item.text}</p>),
      )}
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage
          ? "Loading more..."
          : hasNextPage
            ? "Load more"
            : "Nothing more to load"}
      </button>
    </>
  );
}

Returning null or undefined from getNextPageParam sets hasNextPage to false.

Prefetching

You can start loading data before a component needs it, for example when the user hovers a link:

import { useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router";
import { todoQueries } from "./queries";

export function TodoLink({ id, title }: { id: number; title: string }) {
  const queryClient = useQueryClient();

  return (
    <Link
      to={`/todos/${id}`}
      onMouseEnter={() => queryClient.prefetchQuery(todoQueries.detail(id))}
    >
      {title}
    </Link>
  );
}

prefetchQuery respects staleTime, so hovering repeatedly does not trigger repeated requests while the data is fresh. Router loaders are another good place to prefetch, using queryClient.ensureQueryData to fetch only when the cache is empty.

Common Mistakes With TanStack Query

  • Leaving variables out of the query key. If the queryFn uses id but the key does not include it, every id shares one cache entry and you see the wrong data. The official ESLint plugin catches this.
  • Not throwing on HTTP errors. fetch resolves for 404 and 500 responses, so check res.ok and throw.
  • Copying query data into local state. useState(data) captures the first value and ignores refetches. Read from the query directly and derive what you need during render.
  • Creating the QueryClient inside a component. A new client on each render means an empty cache every time. Create it once at module level or in a useState initializer.
  • Confusing staleTime with gcTime. staleTime controls refetching; gcTime controls memory cleanup of unused data.
  • Invalidating too narrowly. After a mutation, invalidate the shared prefix (like ["todos"]) so lists, details, and counts all stay consistent.

Frequently Asked Questions (FAQ) About TanStack Query

Yes. React Query was renamed TanStack Query in v4 when it gained adapters for other frameworks. The React package is @tanstack/react-query, and the APIs described here are from v5.

It replaces the parts of those stores that held server data, along with the loading flags and thunks around them. You may still need a small client state solution for UI state like modals, themes, or multi-step drafts, and that pairing works well.

That is refetchOnWindowFocus, which is on by default and only refetches stale queries. Increase staleTime so data stays fresh longer, or set refetchOnWindowFocus to false for queries where it is not useful.

isPending means the query has no data yet. isLoading is isPending combined with isFetching, so it is true only while the first fetch is actually running. A disabled query with no data is pending but not loading.

Yes. Use useSuspenseQuery, which suspends until data is available and always returns defined data. Wrap the component in a Suspense boundary for loading and an error boundary for failures.

No. TanStack Query does not care how you fetch. Any function that returns a promise works, whether it uses fetch, axios, or a generated API client. Just make sure it rejects on errors.

Conclusion

TanStack Query treats server data as a cache keyed by what it depends on. useQuery handles fetching, deduplication, retries, and background refetching, while staleTime and gcTime control how fresh and how long-lived that cache is. useMutation with invalidateQueries keeps everything consistent after changes, and onMutate with a rollback context gives you optimistic updates when speed matters.

Start by moving one data-heavy screen from useEffect fetching to useQuery, add the DevTools to watch the cache in action, then introduce queryOptions factories as the number of queries grows. If you want a broader comparison of data loading approaches first, see fetching data in React with fetch, axios, and beyond.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading