webdev.complete
🪣 Data Fetching & Forms
⚛️React
Lesson 86 of 117
35 min

TanStack Query

Queries, mutations, optimistic updates, query invalidation.

Fetching data with useEffect + useState looks easy until you realize you need: loading states, error handling, retries, caching, deduplication, refetching when the window refocuses, pagination, optimistic updates, and request cancellation. That's a 500-line file. Or you can install TanStack Query and get all of it in three lines.

Why this exists

TanStack Query (formerly React Query) is the de facto answer for server state in React. Server state has very different rules from client state:

  • It lives somewhere you don't control (a server).
  • It can become stale at any moment.
  • Multiple components often need the same data.
  • You need to invalidate it when things change.

useState + useEffect is the wrong tool for any of that.

useQuery: the basic shape

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

function UserProfile({ id }) {
  const { data, isPending, error } = useQuery({
    queryKey: ["user", id],
    queryFn: () => fetch(`/api/users/${id}`).then((r) => r.json()),
  });

  if (isPending) return <Spinner />;
  if (error) return <ErrorPanel error={error} />;
  return <h1>{data.name}</h1>;
}

That's the whole pattern. You give it two things:

  • A queryKey (an array) that uniquely identifies this piece of data.
  • A queryFn that returns a promise resolving to the data.

TanStack Query handles loading state, errors, retries, caching by key, and deduplication (ten components asking for the same key fire one request).

Query keys: think of them as cache addresses

The key is how TanStack identifies a query. Two components with the same key share data and a single network request. Different keys = different cache entries.

tsx
// Same data, one request
useQuery({ queryKey: ["users"], queryFn: getUsers });
useQuery({ queryKey: ["users"], queryFn: getUsers });

// Per-id user queries
useQuery({ queryKey: ["user", id], queryFn: () => getUser(id) });

// Lists with filters as part of the key
useQuery({ queryKey: ["todos", { status: "open", page: 1 }], queryFn: ... });

Convention: start broad, narrow with details. ["users"] for the list, ["user", id] for one, ["users", { /* filters */ }] for filtered lists. The key's prefix lets you invalidate everything under it at once.

staleTime vs gcTime

Two different timers, and they confuse everyone.

  • staleTime: how long the data is considered "fresh." While fresh, TanStack returns the cached value without a background refetch. Default: 0 (everything is stale instantly).
  • gcTime (formerly cacheTime): how long unused cache entries stick around in memory before being garbage-collected. Default: 5 minutes.

Set staleTime: 60_000on data that doesn't change often, like a user profile. You'll cut your network traffic in half.

tsx
useQuery({
  queryKey: ["user", id],
  queryFn: () => getUser(id),
  staleTime: 5 * 60_000, // fresh for 5 minutes
});

Mutations: useMutation

Queries read. Mutations write. POST, PUT, DELETE all use useMutation:

tsx
const queryClient = useQueryClient();

const addTodo = useMutation({
  mutationFn: (text) => fetch("/api/todos", {
    method: "POST",
    body: JSON.stringify({ text }),
  }).then((r) => r.json()),

  onSuccess: () => {
    // Invalidate the list so it refetches
    queryClient.invalidateQueries({ queryKey: ["todos"] });
  },
});

// In a handler
addTodo.mutate("Buy milk");

The pattern: mutate → invalidate. After a successful mutation, call invalidateQueries on the lists that might have changed. Anything currently rendered with that key refetches automatically.

Optimistic updates with onMutate

Want the UI to update beforethe server confirms? That's what optimistic updates are. onMutate runs immediately; you tweak the cache directly. If the server fails, you roll back.

tsx
useMutation({
  mutationFn: (todo) => updateTodo(todo),
  onMutate: async (newTodo) => {
    await queryClient.cancelQueries({ queryKey: ["todos"] });
    const previous = queryClient.getQueryData(["todos"]);
    queryClient.setQueryData(["todos"], (old) =>
      old.map((t) => t.id === newTodo.id ? newTodo : t)
    );
    return { previous }; // context for rollback
  },
  onError: (err, newTodo, ctx) => {
    queryClient.setQueryData(["todos"], ctx.previous);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ["todos"] });
  },
});

Lots of code for a click feeling instant. In 2026 React 19 also has useOptimistic which is simpler for many cases (see the next chapter).

Setup once, use everywhere
Wrap your app once in <QueryClientProvider client={client}>. Every useQuery and useMutation below it shares the same cache. Most apps need exactly one client.

Try it: a working query app

Below is a tiny app using a mock in-memory "server." Watch the network counter: queries dedupe, refetch-on-focus, and the cache keeps results between view toggles. Try adding a delay to see loading states.

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

// Fake server in module scope
let DB = [
  { id: 1, text: "Learn TanStack Query" },
  { id: 2, text: "Build optimistic updates" },
];
let NEXT_ID = 3;

async function fetchTodos() {
  await new Promise((r) => setTimeout(r, 600));
  return [...DB];
}
async function addTodo(text) {
  await new Promise((r) => setTimeout(r, 400));
  const todo = { id: NEXT_ID++, text };
  DB.push(todo);
  return todo;
}

function TodoList() {
  const qc = useQueryClient();
  const [text, setText] = useState("");

  const { data, isPending, isFetching } = useQuery({
    queryKey: ["todos"],
    queryFn: fetchTodos,
    staleTime: 10_000,
  });

  const add = useMutation({
    mutationFn: addTodo,
    onSuccess: () => qc.invalidateQueries({ queryKey: ["todos"] }),
  });

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

  return (
    <div>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          if (text.trim()) { add.mutate(text); setText(""); }
        }}
      >
        <input value={text} onChange={(e) => setText(e.target.value)} placeholder="New todo" />
        <button disabled={add.isPending}>
          {add.isPending ? "Adding..." : "Add"}
        </button>
      </form>
      <p style={{ fontSize: 12, color: isFetching ? "#f59e0b" : "#6b7280" }}>
        {isFetching ? "Refetching in background..." : "Fresh"}
      </p>
      <ul>{data.map((t) => <li key={t.id}>{t.text}</li>)}</ul>
    </div>
  );
}

const qc = new QueryClient();
export default function App() {
  return (
    <QueryClientProvider client={qc}>
      <div style={{ padding: 24, fontFamily: "system-ui" }}>
        <h3>Todos (TanStack Query)</h3>
        <TodoList />
      </div>
    </QueryClientProvider>
  );
}

Quiz

Quiz1 / 3

What does a query key do?

Recap

  • Server state needs its own tool. TanStack Query is the React ecosystem standard.
  • useQuery takes a queryKey and queryFn. It handles loading, error, retry, caching, dedupe.
  • staleTime controls how long data is considered fresh. Tune it per query type.
  • useMutation for writes. After success, call invalidateQueries.
  • onMutate + setQueryData = optimistic updates. Roll back in onError.
  • Stop writing fetch effects. This is the answer.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.