Skip to content
elephantoo

Server state with TanStack Query

Lesson 24 of 30 18 min read

Queries, caching, background refetching, mutations, invalidation and optimistic updates.


Fetching data with useEffect works, but a real app needs much more: caching so revisiting a page is instant, de-duplicating requests, refetching stale data when the user returns to the tab, retrying failures, pagination, and updating the UI after changes. TanStack Query (formerly React Query) handles all of that. You describe what data you need; it manages when and how it's fetched.

Setup#

Terminal
npm install @tanstack/react-query

Create one QueryClient and provide it at the root:

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

const queryClient = new QueryClient({
  defaultOptions: {
    queries: { staleTime: 60_000 }, // treat data as fresh for 1 minute
  },
});

createRoot(document.getElementById("root")).render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);

Your first query#

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

async function fetchPosts() {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=5");
  if (!res.ok) throw new Error(`HTTP ${res.status}`); // fetch doesn't throw on 404/500 – you must
  return res.json();
}

export default function Posts() {
  const { data, isPending, isError, error, isFetching, refetch } = useQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts,
  });

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

  return (
    <>
      <button onClick={() => refetch()} disabled={isFetching}>
        {isFetching ? "Refreshing…" : "Refresh"}
      </button>
      <ul>
        {data.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </>
  );
}

Compare this with the effect version: no useState, no cleanup, no race conditions. And if two components call useQuery({ queryKey: ["posts"] }), only one request is made — both read the same cache entry.

The status flags

  • isPending — no data yet (first load).
  • isError / error — the queryFn threw (after 3 automatic retries by default).
  • isSuccess / data — data is available.
  • isFetching — a request is in flight, including background refetches while old data is still shown.

Query keys and parameters#

The queryKey is an array that identifies the data. Put every variable the query depends on in it:

JSX
function fetchPost(id) {
  return fetch(`https://jsonplaceholder.typicode.com/posts/${id}`).then((res) => {
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  });
}

function PostDetail({ postId }) {
  const { data: post, isPending } = useQuery({
    queryKey: ["posts", postId], // a different cache entry per post
    queryFn: () => fetchPost(postId),
    enabled: postId != null, // don't run until we have an id
  });

  if (isPending) return <p>Loading…</p>;
  return <h2>{post.title}</h2>;
}

When postId changes, the key changes and TanStack Query fetches the new post — or returns it instantly from cache if you've seen it before.

Tip: wrap queries in custom hooks (usePost(id)) so keys and fetchers live in one place.

Caching: staleTime and gcTime#

Two settings control the cache:

  • staleTime (default 0): how long data counts as fresh. Fresh data is served from cache without refetching. Stale data is still shown immediately, but refetched in the background when a component mounts, the window regains focus, or the network reconnects.
  • gcTime (default 5 minutes): how long unused data stays in memory before it's garbage-collected.
JSX
useQuery({
  queryKey: ["courses"],
  queryFn: fetchCourses,
  staleTime: 5 * 60_000, // course list rarely changes
});

Pagination without flicker#

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

function PagedPosts() {
  const [page, setPage] = useState(1);
  const { data, isPending, isPlaceholderData } = useQuery({
    queryKey: ["posts", "page", page],
    queryFn: () =>
      fetch(`https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=10`).then((r) => r.json()),
    placeholderData: keepPreviousData, // show the old page while the next loads
  });

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

  return (
    <>
      <ul style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
        {data.map((p) => (
          <li key={p.id}>{p.title}</li>
        ))}
      </ul>
      <button onClick={() => setPage((p) => p - 1)} disabled={page === 1}>
        Previous
      </button>
      <span> Page {page} </span>
      <button onClick={() => setPage((p) => p + 1)} disabled={isPlaceholderData || page === 10}>
        Next
      </button>
    </>
  );
}

For "load more" and infinite scrolling, use useInfiniteQuery.

Mutations: changing data#

Use useMutation for creates, updates and deletes. After success, invalidate related queries so they refetch:

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

async function createPost(newPost) {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(newPost),
  });
  if (!res.ok) throw new Error("Could not create post");
  return res.json();
}

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

  const mutation = useMutation({
    mutationFn: createPost,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["posts"] }); // refetch every query starting with "posts"
    },
  });

  function handleSubmit(e) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    mutation.mutate({ title: formData.get("title"), body: "", userId: 1 });
    e.currentTarget.reset();
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" required />
      <button disabled={mutation.isPending}>{mutation.isPending ? "Saving…" : "Add post"}</button>
      {mutation.isError && <p role="alert">{mutation.error.message}</p>}
      {mutation.isSuccess && <p>Created post #{mutation.data.id}</p>}
    </form>
  );
}

invalidateQueries matches by prefix: ["posts"] invalidates ["posts"], ["posts", 5] and ["posts", "page", 2].

Optimistic updates#

For snappy UIs, update the cache before the server responds and roll back if it fails:

JSX
function useToggleTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (todo) =>
      fetch(`https://jsonplaceholder.typicode.com/todos/${todo.id}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ completed: !todo.completed }),
      }).then((r) => r.json()),

    onMutate: async (todo) => {
      await queryClient.cancelQueries({ queryKey: ["todos"] }); // stop refetches overwriting us
      const previous = queryClient.getQueryData(["todos"]);
      queryClient.setQueryData(["todos"], (old) =>
        old.map((t) => (t.id === todo.id ? { ...t, completed: !t.completed } : t))
      );
      return { previous }; // passed to onError
    },

    onError: (_error, _todo, result) => {
      queryClient.setQueryData(["todos"], result.previous); // roll back
    },

    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] }); // sync with the server either way
    },
  });
}

Prefetching#

Start fetching before the user clicks, e.g. on hover:

JSX
function PostLink({ id, title }) {
  const queryClient = useQueryClient();
  return (
    <a
      href={`/posts/${id}`}
      onMouseEnter={() => queryClient.prefetchQuery({ queryKey: ["posts", id], queryFn: () => fetchPost(id) })}
    >
      {title}
    </a>
  );
}

Suspense mode#

useSuspenseQuery suspends instead of returning isPending, so loading and errors are handled by <Suspense> and error boundaries, and data is always defined:

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

function UserName({ id }) {
  const { data: user } = useSuspenseQuery({
    queryKey: ["users", id],
    queryFn: () => fetch(`https://jsonplaceholder.typicode.com/users/${id}`).then((r) => r.json()),
  });
  return <span>{user.name}</span>;
}

DevTools#

Terminal
npm install -D @tanstack/react-query-devtools
JSX
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

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

A floating panel shows every query, its key, status and cached data — invaluable for debugging. It's excluded from production builds automatically.

Common mistakes#

  • Leaving a variable out of the queryKey, so different ids share one cache entry.
  • Forgetting to throw on !res.ok, so error responses are cached as data.
  • Creating the QueryClient inside a component — it's recreated on every render and the cache is lost.
  • Copying query data into useState (it goes stale). Use data directly, or derive from it.
  • Confusing isPending (no data yet) with isFetching (any request in flight).
  • Using TanStack Query for purely client state like a modal's open flag.

What's next#

Your apps are getting serious. Next: TypeScript with React, to catch bugs before they reach users.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What is the queryKey in useQuery({ queryKey: ['posts', page], queryFn }) used for?

  2. 2.After a mutation creates a new post, how do you make the posts list refresh?

  3. 3.In TanStack Query v5, which flag means 'there is no data yet' (the first load)?

Finished reading?

Mark this lesson complete to track your progress.