Two Kinds of State: Why Copying Server Data into useState Breeds Bugs
Half the 'state management' pain in React apps comes from treating a server cache like client state. On the two-state model, sync bugs, and single sources of truth.
Here's a bug I've now seen in at least five different codebases, in five different disguises. A user updates their display name on the settings page. Saves. Green toast, everything's great. Then they navigate to the dashboard — and the header greets them by their old name. Refresh the page and the new name appears everywhere. File under "works after refresh," the most common bug genre in single-page apps.
Every version of this bug I've dug into had the same root cause. Not a bad API, not a caching header, not a race. It was this, or a cousin of it:
function ProfileHeader() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
api.getUser().then(setUser);
}, []);
// ...
}
The server's data got copied into local component state — and a copy, from the moment it's made, is a snapshot drifting away from the truth. The settings page updated the server and its own copy. The header's copy never heard about it. Two components, two private snapshots of "the user," no mechanism keeping either honest.
The two-state model
The mental shift that untangles this: your app doesn't have one kind of state, it has two, and they have almost nothing in common.
Client state is state your app owns: which modal is open, the draft text in an input, the selected tab, dark mode. It's synchronous, it's authoritative (nobody else can change it), and it dies with the session. useState, useReducer, or a small store — all fine. This is what those tools are for.
Server state is state you borrow: the user's profile, the invoice list, the notification count. You don't own it — the server does. It can change without your knowledge (another tab, another device, a coworker, a cron job). It arrives asynchronously, can fail to arrive, and is stale from the moment you receive it. What lives on the client isn't the state; it's a cache of the state.
Almost every "state management is a nightmare" story I've heard traces to treating the second kind like the first: fetching server data, copying it into useState or a global store, and then hand-writing all the cache logic — invalidation, deduplication, refetching, cross-screen consistency — one bug report at a time. The header bug isn't a state bug. It's a cache-invalidation bug written by someone who didn't know they were building a cache.
What owning-a-cache actually requires
If you copy server data into local state, you have signed up — whether you noticed or not — for the responsibilities of a cache maintainer:
- Invalidation: when a mutation succeeds, every copy anywhere in the app must update or refetch.
- Deduplication: three components needing the user shouldn't fire three requests.
- Staleness policy: when is data old enough to refetch? On focus? On reconnect? On a timer?
- Race handling: the response for query A must not overwrite the newer response for query A′.
This is a genuinely hard, genuinely solved problem. React Query, SWR, RTK Query, Apollo — their entire reason to exist is being that cache so you don't hand-roll one. The component above becomes:
function ProfileHeader() {
const { data: user } = useQuery({ queryKey: ["user"], queryFn: api.getUser });
// ...
}
// In settings, after a successful save:
queryClient.invalidateQueries({ queryKey: ["user"] });
Every component reading ["user"] shares one cache entry — one source of truth — and the invalidation reaches all of them. The header bug is structurally impossible, not carefully avoided. (These libraries get called "server state managers" for a reason; picking Redux-versus-Zustand for server data is answering the wrong question entirely.)
The syncing anti-pattern, and its favorite disguise
Even on teams that use a query library, the copy-drift bug sneaks back in through one specific door:
function EditForm({ user }: { user: User }) {
const [name, setName] = useState(user.name);
useEffect(() => {
setName(user.name); // "keep it in sync" 🚩
}, [user.name]);
// ...
}
That effect is a confession: "I made a copy and now I'm chasing the original." And this one also destroys user input — a background refetch fires while the user is mid-edit, the effect runs, and their typing is replaced by the server value.
The resolution is to decide, explicitly, who owns the value right now:
- Displaying server data? Don't copy it. Render from the query result directly. Derive, don't duplicate.
- Editing server data? Then this is a draft — genuinely client state, seeded once from server data, owned by the form until save. Use the prop as
defaultValue/initial state and don't sync it. If a fresh edit session should reset the form, remount it withkey={user.id}instead of syncing with effects.
The moment of copying is fine; it's the ongoing syncing that breeds bugs. A seed has one owner. A synced copy has two, and two owners of one value is how state fights break out.
A quick ownership audit
For any piece of state in a diff, three questions sort nearly everything:
- Can the server change this without the client knowing? → Server state. It belongs in the query cache, accessed by key, invalidated on mutation.
- Is this the user's in-progress work? → Draft state. Seed it, own it locally, write it back on save, never auto-sync into it.
- Does this only describe the UI itself? → Client state.
useStateand friends; keep it as close to where it's used as possible.
The red flags that make me slow down in review: useState initialized from a prop that came from a fetch; useEffect whose body is just setX(prop); server responses being dispatched into a global store "so other screens can use it"; and mutation handlers that update a local copy but invalidate nothing.
I'll add: AI assistants generate the fetch-into-useState pattern constantly. It's the dominant pattern in a decade of tutorials, so it's the statistically likely completion, and it looks clean — hook, effect, loading flag, done. If your team uses a query library, generated code will often bypass it entirely and quietly introduce a second, unmanaged copy of data the cache already owns. That's two sources of truth added in one diff, and it will demo perfectly. "Works after refresh" bugs get written months before they're reported; review is where they're cheap to catch.
The short version
- Client state you own; server state you borrow — what's on the client is a cache.
- Copying server data into local state signs you up for cache invalidation, dedup, staleness, and races. Use a query library instead of hand-rolling it badly.
- Render server data from the cache; never
useEffect-sync it into local copies. - Forms are the exception done deliberately: seed a draft once, own it locally, reset by remounting with
key. - In review, hunt for second sources of truth — especially in generated code, where fetch-into-
useStateis the default reflex.
One source of truth per fact. Everything else is derived, or it's drift.