Server state is not UI state. Learn why useEffect fetching breaks, what TanStack Query gives you, and how caching, invalidation, optimistic updates, and RSC fit together.
Frontend developers who can wire up a component and call an API, but keep getting bitten by stale data, flickering spinners, and bugs that only happen when the network is slow. If you have ever written `useEffect(() => { fetch(...) }, [])` and shipped it, this is for you.
You build a profile page. You fetch the user in a useEffect, store it in useState, render a spinner while it loads. It works on your machine. Then a user clicks between two profiles fast, and the page shows the wrong person. Another user opens the same screen twice and you fire the same request twice. Someone leaves the tab and comes back to data that is ten minutes old. None of these are bugs in your code exactly, they are gaps in the *model* you used.
The fix is not a cleverer useEffect. It is recognizing that the data you fetch from a server is a fundamentally different kind of state than the data your UI owns, and that there is a whole category of library built to manage it. This article shows why, and how to do it right.
Server state is someone else's data, cached
UI state is state you own and can change at will. Server state is a local, temporary, possibly-stale copy of data that actually lives somewhere else, and can change without telling you.
Think about who is the source of truth. The toggle that says a dropdown is open? You own it. Nobody else can flip it. That is UI state, synchronous, yours, gone on refresh, and perfectly at home in useState or a store (see state management in frontend apps).
The list of orders for a customer? You do not own that. It lives in a database behind an API. Your component holds a *copy*. That copy is stale the instant another user, another tab, or a background job changes it. This is server state, and the hard parts, caching, knowing when the copy is too old, refetching, deduping, retrying a flaky network, are all about managing a copy of data you do not control.
A sticky note on your deskUI state, you wrote it, only you change it, instant to read
A library book you photocopiedServer state, a copy of the real thing, which the library can update without telling you
Wondering if a newer edition existsStaleness, is my cached copy still good enough?
Quietly grabbing the new edition while you keep reading the old oneBackground refetch / stale-while-revalidate
Server state behaves like a cached library book, not like a sticky note you wrote.
Why useEffect + useState is a trap
Here is the canonical hand-rolled fetch. Read it and count the problems before the list below.
Race condition. Change userId from A to B quickly and both requests are in flight. If A's response lands after B's, you render A's data for B. There is no cancellation or request-versioning here.
No caching. Navigate away and back and you refetch from scratch, a spinner every time, even though you had the data two seconds ago.
No dedupe. Two components asking for the same user fire two identical requests. Nothing coordinates them.
No background refresh. Once loaded, the data is frozen until the component remounts. Stale data sits on screen indefinitely.
No retries, no focus refetch. A flaky request just fails. Coming back to the tab after an hour shows ancient data.
Boilerplate, repeated forever. Three pieces of state and the same try/catch dance in every component that fetches anything.
You can fix each of these by hand
Add an `ignore` flag for the race, a module-level Map for the cache, an in-flight registry for dedupe, a visibility listener for refetch... and now you have written a worse, untested version of a server-state library. That is the real lesson: this problem is solved, just not by you.
The same thing with a server-state library
A server-state library, TanStack Query, SWR, or RTK Query, turns all of that into a few lines. Here is the profile with TanStack Query.
UserProfile.tsx
tsx
import { useQuery } from'@tanstack/react-query';
functiongetUser(userId: string): Promise<User> {
returnfetch(`/api/users/${userId}`).then((r) => {
if (!r.ok) thrownewError('Failed to load user');
return r.json();
});
}
functionUserProfile({ userId }: { userId: string }) {
const { data, isPending, isError, error, refetch } = useQuery({
queryKey: ['user', userId], // identity of this data
queryFn: () => getUser(userId),
staleTime: 30_000, // fresh for 30s; no refetch within window
});
if (isPending) return <Spinner />;
if (isError) return <ErrorBox error={error} onRetry={refetch} />;
return <Profile user={data} />;
}
Same line count, but the race condition is gone (results are keyed by ['user', userId], so a late response for the old id can never overwrite the new one), the data is cached (revisit = instant render from cache), identical queries are deduped into one request, it retries failed requests, and it refetches in the background when the data goes stale or the window regains focus. You wrote the queryFn; the library owns everything around it.
What you get for free
Caching keyed by a query key, revisits render instantly.
Automatic dedupe of identical in-flight requests.
Background refetch on stale, on focus, on reconnect.
Retries with backoff on failure.
Race-safety: responses are reconciled by key, not arrival order.
One consistent loading/error/data shape everywhere.
Stale-while-revalidate: the mental model
The behavior that makes these libraries feel fast is stale-while-revalidate (SWR): when you ask for data that is already cached but stale, you get the cached copy *immediately* so the UI renders with no spinner, and a refetch fires in the background. When the fresh data arrives, the cache updates and the UI re-renders. The user sees content instantly, then a quiet update.
A second visit to the same query: render from cache now, refetch in the background, update when fresh data lands.
1
Ask
The component calls useQuery with a key. The library checks the cache for that key.
2
Serve stale
There is a cached entry but it is older than staleTime. Return it immediately so the UI paints with real content.
3
Revalidate
In parallel, run the queryFn to fetch fresh data from the server.
4
Reconcile
When the response arrives, write it to the cache under the same key.
5
Update
Every component subscribed to that key re-renders with fresh data, usually an invisible swap.
staleTime vs gcTime
`staleTime` is how long a cached value is considered fresh (no background refetch within it). `gcTime` (garbage-collection time) is how long an unused cache entry survives after the last component using it unmounts. Tune `staleTime` up for data that rarely changes; leave it at 0 for data that must always revalidate.
Server state vs UI state, side by side
Before reaching for a tool, classify the state. If it is server state, it belongs in a query cache, not in useState or your global store.
Property
UI / client state
Server state
Owner
You (the client)
The server / database
Source of truth
In the browser
Remote, you hold a copy
Freshness
Always current
Can go stale silently
Persistence
Ephemeral (lost on refresh)
Durable, shared across clients
Sync need
None
Cache, refetch, invalidate, retry
Good tool
useState / Zustand / Context
TanStack Query / SWR / RTK Query
Examples
Modal open, form input, theme
User profile, orders, search results
The two categories rarely want the same tool.
The most common smell
Copying fetched data into a global store "so other components can use it" and then writing effects to keep it in sync. That is rebuilding a cache by hand. Let the query cache be the store for server data; keep your client store for things the client actually owns.
Mutations, optimistic updates, and rollback
Reads are queries; writes are mutations. After a write, the cache holds stale data, so a mutation's job is not just to POST, it is to make the affected queries refetch. The simplest version invalidates by key on success.
useRenameTodo.ts
tsx
import { useMutation, useQueryClient } from'@tanstack/react-query';
functionuseRenameTodo() {
const qc = useQueryClient();
returnuseMutation({
mutationFn: (vars: { id: string; title: string }) =>
fetch(`/api/todos/${vars.id}`, {
method: 'PATCH',
body: JSON.stringify({ title: vars.title }),
}).then((r) => r.json()),
onSuccess: () => {
// mark the list stale -> it refetches with the new title
qc.invalidateQueries({ queryKey: ['todos'] });
},
});
}
Invalidation is correct but it waits for a round trip before the UI changes. For instant feedback, do an optimistic update: write the expected result to the cache immediately, then roll back if the server rejects it.
Cancel in-flight refetches, snapshot the current cache, and apply the optimistic change so the UI updates instantly.
2
onError
If the server rejects, roll the cache back to the snapshot you returned from onMutate.
3
onSettled
Whether it succeeded or failed, invalidate so the next read reconciles with the server's real state.
Always snapshot before you mutate optimistically
The rollback only works because onMutate returns the previous cache value. Skip the snapshot and a failed mutation leaves the UI showing a change the server never accepted, the exact silent-corruption bug optimistic updates are supposed to prevent.
Pagination and infinite queries
Lists that load in pages have their own trap: naive pagination flickers to a spinner on every page change. Use placeholderData to keep the previous page on screen while the next loads, and useInfiniteQuery for endless-scroll feeds.
infinite-and-paged.tsx
tsx
import { useInfiniteQuery, keepPreviousData, useQuery } from'@tanstack/react-query';
// Paged: stays on the old page (no spinner flash) until the new one arrivesfunctionusePagedTodos(page: number) {
returnuseQuery({
queryKey: ['todos', { page }],
queryFn: () => fetchTodos(page),
placeholderData: keepPreviousData,
});
}
// Infinite: each page appended; getNextPageParam drives "load more"functionuseFeed() {
returnuseInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeed(pageParam),
initialPageParam: 0,
getNextPageParam: (last) => last.nextCursor ?? undefined,
});
}
Put query params in the key
Note `['todos', { page }]`, page, filters, and sort all belong in the query key. Each combination caches independently, so flipping back to page 1 is instant and changing a filter fetches a distinct, cached result.
Avoiding request waterfalls
A waterfall is when request B cannot start until request A finishes, not because B needs A's data, but because of how you wrote it. Components that each fetch in their own effect, nested, create a cascade: each level mounts, fetches, renders its child, which then mounts and fetches. The page is as slow as the sum of every hop.
parallel-and-prefetch.tsx
tsx
// BAD: sequential, orders waits for user even though it doesn't need itconst user = awaitgetUser(id);
const orders = awaitgetOrders(id);
// GOOD: independent requests run in parallelconst [user, orders] = await Promise.all([getUser(id), getOrders(id)]);
// In React: independent useQuery calls already run in parallel.// useQueries batches a dynamic list of them:const results = useQueries({
queries: ids.map((id) => ({ queryKey: ['user', id], queryFn: () => getUser(id) })),
});
// Prefetch on intent (hover/route enter) so data is warm before render:functiononHover(id: string) {
queryClient.prefetchQuery({ queryKey: ['user', id], queryFn: () => getUser(id) });
}
Two cures for waterfalls
1) **Parallelize** anything independent, `Promise.all`, sibling `useQuery` calls, or `useQueries`. 2) **Prefetch** on intent, warm the cache on link hover or route entry so the data is already there when the component mounts. Hoisting fetches to a parent (or a route loader) also flattens nested cascades.
Where this meets React Server Components
React Server Components (RSC) change *where* fetching happens. A server component runs on the server and can await data directly, no useEffect, no client cache, no loading state shipped to the browser. The HTML arrives with data already in it. That removes a class of client-side fetching entirely. (See React Server Components and the App Router.)
app/users/[id]/page.tsx
tsx
// Server Component, runs on the server, awaits data, no client JS for the fetchexportdefaultasyncfunctionUserPage({ params }: { params: { id: string } }) {
const user = awaitgetUser(params.id); // direct, on the serverreturn <Profile user={user} />;
}
So does a server-state library still matter? Yes, for everything interactive. RSC is great for the initial, read-mostly render. But mutations, optimistic updates, polling, infinite scroll, refetch-on-focus, and anything that changes after load still live on the client. The modern pattern is both: fetch the initial data in a server component, then hydrate it into TanStack Query so client components take over with a warm cache and full interactivity.
Need
Server component
Client query library
Initial read on first paint
Ideal, no client JS
Works, but ships a fetch
Mutations + optimistic UI
No
Yes
Refetch on focus / interval
No
Yes
Infinite scroll / pagination
Awkward
Built-in
SEO-critical content
Ideal
Needs hydration
Pick the layer by what the data needs to do.
Common mistakes that cost hours
Treating server data like UI state, stuffing fetched data into a global store and syncing it with effects. Let the query cache be the source of truth for remote data.
Unstable query keys, building the key from a fresh object or omitting a param. The cache fragments, dedupe breaks, and you refetch constantly. Keys must be serializable and include every input.
Optimistic update without a snapshot, no rollback path means a failed write silently leaves the UI lying.
Forgetting to invalidate after a mutation, the write succeeds but the list still shows the old data because nothing told it to refetch.
Sequential awaits for independent data, a self-inflicted waterfall; Promise.all or parallel queries fix it.
staleTime of 0 everywhere, every focus and remount refetches, hammering the API. Match staleTime to how often the data really changes.
Disabling retries globally then blaming flakiness, or retrying non-idempotent mutations. Retry reads; be careful with writes.
Takeaways and where to go next
The whole article in nine lines
Server state is a cached copy of data you do not own; UI state is data you do.
useEffect + useState fetching has built-in race conditions, no cache, no dedupe, no refetch.
A server-state library gives you caching, dedupe, background refetch, retries, and race-safety for free.
Stale-while-revalidate: show cached data now, refetch in the background, update when it lands.
Query keys are the identity of cached data, keep them stable and include every input.
Mutations should invalidate affected queries; add optimistic updates with a snapshot for rollback.
Use placeholderData for paging and useInfiniteQuery for feeds.
Kill waterfalls by parallelizing independent requests and prefetching on intent.
RSC moves initial reads to the server; client query libraries still own all interactivity.
Data fetching is one of three state problems on the frontend, the other two are local UI state and rendering. Solidify the neighbors and the whole picture clicks into place.
state management in frontend apps, where server state fits among the kinds of state, and why your global store should not hold remote data.
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.