State Management — Build the Library Yourself
Server State Is a Cache, Not State
In one line
A value that the server holds the truth for is a cache, not state. The moment you put the two in the same bucket, there is nowhere in the code to write the question "when did it go stale?"
Why this was needed
You have probably put something shaped like this in a store.
{ user: null, orders: [], loading: false, error: null }
Two different kinds are mixed in here. user and orders are values owned by the server.
Even if I don't touch them, someone else changes them. On the other hand, whether a modal is open and which tab
is being viewed are values only this browser knows. No one changes them behind the scenes.
If you put them in the same bucket, there is nowhere to write the questions that only server-side values need — when
to refetch, how stale what you are looking at is, whether two screens are looking at the same thing. So
those questions scatter into screen code: a fetch inside useEffect, a "refresh" button, manual
resets at router transitions. None of them follow the same rule, so you end up with only one screen showing a stale value.
The official React docs say the same thing from another angle. Don't put values that can be computed from the render result in state (You Might Not Need an Effect), and structure state without duplication (Choosing the state structure). Copying a server response verbatim is the biggest form of that duplication.
How it works
The dividing criterion is "who changes it"
One question is enough. Can anyone other than me change this value?
| Question | Answer is yes | Answer is no |
|---|---|---|
| Name | Server state | Client state |
| Examples | Order list, balance, other people's comments | Modal open, selected tab, text being typed |
| What it needs | Key, staleness check, invalidation | Just a value |
| Where it lives | Query cache | Store or local state |
Server state must have, along with the value, a key and a received time. Without those two you can't ask "do I need to refetch?"
Stale and discard (gc) are different scales
Many people think of these two as one. In fact there are two axes.
- Stale — a state where it is fine to show it on screen right now, but it should be refetched in the background
- Discard — a state where nobody is looking at it, so it can be deleted from memory
The defaults of TanStack Query show this distinction well. By default, data you fetch is
treated as stale immediately (staleTime 0), so when a screen remounts or the window regains
focus, it is refetched in the background. On the other hand, query results that nobody is watching are
cleaned up after 5 minutes (gcTime defaults to 1000 * 60 * 5)
(Important Defaults).
What matters is why the defaults were set that way. If you default to "stale," the worst case is one unnecessary request, but if you default to "fresh," the worst case is continuing to show a wrong value. Which of the two failures is cheaper is not something you need to agonize over.
Invalidation means "ask again," not "delete"
You canceled an order. If you delete the list cache, the screen blinks blank and then fills back in. If instead you
only mark it stale and keep the value, it keeps showing what was being viewed while fetching the new value in the background and
quietly swapping it in. As the same doc notes, results are structurally shared, so if nothing actually changed,
the reference is kept as is — the reference comparison and memoization you built in st-core earn their keep right here.
What it looks like in the field
One team put the logged-in user's information in Redux and fetched it only once at app start. Even when the user changed their name in another tab, this tab kept showing the old name for hours, and "log out and log back in" became the official guidance. That is the price of treating server state like client state.
Accidents in the opposite direction are also common. A team put the text being typed into the query cache, and when a background refetch ran, it overwrote the text the user was typing. This is what happens when you treat a value that nobody changes behind the scenes as server state.
There is one practical criterion to use when the dividing line is blurry. If you open two tabs, change something in one, and the other should follow, it is server state. If it doesn't need to follow, it is client state. That one sentence settles most cases.
Some values sit on the boundary. A shopping cart is client state before login and becomes server state after login. In such a case the answer is not "both" but to decide which side is the owner and move the rest to that side. If you keep two copies as the truth at the same time, you get merge code for which no one can explain which side wins, and that code will surely lose items.
What to check in the next quiz
Six questions check the criterion that separates server state, why stale and discard are different scales, and what shows on screen when you write invalidation as "delete."