March 26, 20267 min read

TanStack Query — Server State Management Done Right

How TanStack Query handles caching, refetching, pagination, and optimistic updates — and why it replaces most of your Redux/Zustand server state logic.

tanstack react query server state fetching cache
Ad 336x280

Most React apps have two kinds of state: client state (modal open/closed, form inputs, UI preferences) and server state (user data, posts, comments, anything from an API). The mistake the industry made for years was using the same tool for both. Redux stores filled with API responses. useEffect + useState spaghetti for every fetch. Manual loading states, error states, refetch logic, cache invalidation, stale data handling — all reimplemented in every component.

TanStack Query (formerly React Query) exists because server state is fundamentally different from client state. Server state is asynchronous, shared across components, can become stale, and needs background updates. TanStack Query handles all of this with a declarative API that eliminates most of the data-fetching code you'd write by hand.

The Problem It Solves

Here's the typical "fetch data in React" pattern without TanStack Query:

// The painful way — every component that fetches data looks like this
function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);

fetch(/api/users/${userId})
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch");
return res.json();
})
.then((data) => {
if (!cancelled) {
setUser(data);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err);
setLoading(false);
}
});

return () => { cancelled = true; };
}, [userId]);

if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (!user) return null;

return <div>{user.name}</div>;
}

That's 30 lines for a single fetch. No caching. No refetching. No deduplication. If two components fetch the same user, you get two network requests.

The TanStack Query Way

bun add @tanstack/react-query
// Wrap your app
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
);
}

// Same component — 8 lines instead of 30
import { useQuery } from "@tanstack/react-query";

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

if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;

return <div>{user.name}</div>;
}

What you get for free:


  • Caching — the result is cached by queryKey. Navigate away and back, no re-fetch.

  • Deduplication — three components using ["user", 1] trigger one network request.

  • Background refetching — data is refreshed when the window regains focus or the component remounts.

  • Stale-while-revalidate — shows cached data immediately while fetching fresh data in the background.

  • Retry — failed requests are retried 3 times by default.

  • Garbage collection — unused cache entries are cleaned up after 5 minutes.


Query Keys

Query keys are the cache keys. They determine when data is shared between components and when it's refetched.

// Simple key
useQuery({ queryKey: ["todos"], queryFn: fetchTodos });

// Key with parameters — different userId = different cache entry
useQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId) });

// Complex keys — filters, pagination
useQuery({
queryKey: ["posts", { status: "published", page: 2, author: "alice" }],
queryFn: () => fetchPosts({ status: "published", page: 2, author: "alice" }),
});

// Keys are compared structurally, not by reference
// ["posts", { page: 1 }] and ["posts", { page: 1 }] share the same cache

Mutations

Queries are for reading data. Mutations are for creating, updating, and deleting.

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

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

const mutation = useMutation({
mutationFn: (newTodo: { title: string }) =>
fetch("/api/todos", {
method: "POST",
body: JSON.stringify(newTodo),
headers: { "Content-Type": "application/json" },
}).then((r) => r.json()),

onSuccess: () => {
// Invalidate the todos cache — triggers a refetch
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});

return (
<form onSubmit={(e) => {
e.preventDefault();
const title = new FormData(e.currentTarget).get("title") as string;
mutation.mutate({ title });
}}>
<input name="title" />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Adding..." : "Add Todo"}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
</form>
);
}

Optimistic Updates

Show the change immediately, then reconcile with the server response:

const mutation = useMutation({
  mutationFn: updateTodo,
  onMutate: async (updatedTodo) => {
    // Cancel in-flight refetches
    await queryClient.cancelQueries({ queryKey: ["todos"] });

// Snapshot previous value
const previous = queryClient.getQueryData(["todos"]);

// Optimistically update cache
queryClient.setQueryData(["todos"], (old: Todo[]) =>
old.map((todo) =>
todo.id === updatedTodo.id ? { ...todo, ...updatedTodo } : todo
)
);

return { previous };
},
onError: (err, variables, context) => {
// Rollback on error
queryClient.setQueryData(["todos"], context?.previous);
},
onSettled: () => {
// Refetch to ensure consistency
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});

Pagination and Infinite Queries

// Standard pagination
function PaginatedPosts() {
  const [page, setPage] = useState(1);

const { data, isLoading, isPlaceholderData } = useQuery({
queryKey: ["posts", page],
queryFn: () => fetchPosts(page),
placeholderData: (previousData) => previousData, // Keep previous data while loading next page
});

return (
<div>
{data?.posts.map((post) => <PostCard key={post.id} post={post} />)}
<div className="flex gap-2">
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1}>
Previous
</button>
<span>Page {page}</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={!data?.hasMore}
style={{ opacity: isPlaceholderData ? 0.5 : 1 }}
>
Next
</button>
</div>
</div>
);
}

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

function InfinitePostFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["posts", "infinite"],
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam, limit: 20 }),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});

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

return (
<div>
{allPosts.map((post) => <PostCard key={post.id} post={post} />)}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? "Loading more..." : "Load More"}
</button>
)}
</div>
);
}

Configuration

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000  60  5,       // Data is fresh for 5 minutes
      gcTime: 1000  60  30,          // Cache garbage collected after 30 minutes
      retry: 2,                         // Retry failed requests twice
      refetchOnWindowFocus: true,       // Refetch when tab regains focus
      refetchOnReconnect: true,         // Refetch when network reconnects
      refetchOnMount: true,             // Refetch when component mounts
    },
    mutations: {
      retry: 0,                         // Don't retry failed mutations
    },
  },
});
Stale time is the most important setting. If your data changes rarely (user profile), set staleTime high. If it changes constantly (chat messages), keep it low or use websockets.

When to Use TanStack Query vs Alternatives

ScenarioRecommendation
Fetching API data in ReactTanStack Query
Global UI state (theme, sidebar)Zustand or Jotai
Complex client-only state machinesXState or Zustand
Form stateReact Hook Form
Real-time data (websockets)TanStack Query + websocket integration
Server-side renderingTanStack Query (has SSR support)
GraphQLTanStack Query or urql
Don't use TanStack Query for:
  • Client-only state (modals, form inputs, local preferences)
  • State that never touches a server
  • Simple apps with one or two API calls (might be overkill)
TanStack Query eliminates an entire category of bugs — stale data, race conditions, duplicate requests, missing loading states. Once you use it on one project, going back to manual fetch logic feels like going back to callbacks after async/await. Explore more React patterns and state management strategies at CodeUp.
Ad 728x90