
Stop Putting Server Data in useState
Most React state bugs are one mistake wearing different hats: treating a copy of the server's data as if you owned it. TanStack Query fixes the category, not the symptom.
There is a bug I have fixed in almost every React codebase I have been handed. It appears as a stale list, a double spinner, a form that saves and then shows the old value. Different symptoms, one cause: somebody put server data in useState.
Server state is not application state. It is a copy of data owned by another machine, which can change without telling you, which several components need at once, and which can fail to arrive. useState models none of that.
What you end up writing by hand
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
fetch('/api/users')
.then(r => r.json())
.then(d => { if (!cancelled) setUsers(d) })
.catch(e => { if (!cancelled) setError(e) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [])
Twelve lines, and it still has no cache, no deduplication, no retry, no refetch when the tab regains focus, and no way for a second component to share the result. Every screen re-implements it slightly differently, which is why the bugs are never quite the same twice.
What it looks like with a query layer
const { data: users, isPending, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
})
The cancellation, caching, deduplication and retry are not written because they are not yours to write. Two components asking for ['users'] get one request. A third mounting a minute later gets the cached array immediately and a background refresh.
The part that changes how you design
Mutations invalidate rather than assign:
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
})
You are not reaching into other components' state to patch it. You are saying this data is now questionable and letting anything that depends on it refetch. That single change removes a whole class of "why didn't the sidebar update" tickets, because no component is responsible for remembering who else cares.
Where it does not help
Genuinely local state — a modal's open flag, the current tab, a draft input value — belongs in useState or a store. Wrapping those in queries adds ceremony and buys nothing. The line is ownership: if a server can change it behind your back, it is server state.
The rule I hold teams to
Anything that arrived over the network goes through the query layer. No exceptions, because the exceptions are where the stale-data bugs live. It is one dependency and a mental model that takes an afternoon, and it deletes more code than it adds on any project past its first week.
Resources
- Repo: TanStack/query
- Docs: tanstack.com/query
- Video walkthroughs: YouTube: tanstack query tutorial
- Related: Zustand for the state you actually own
Need this built properly?
I build secure, fast, bilingual platforms for clients across Egypt, Saudi Arabia, the UAE and Kuwait.


