Server state vs client state: deciding who owns which truth
Server state is a cache of truth the server owns — async, shared, stale by default; a query library manages its lifecycle. Client state is UI truth you own. Copying server data into useState forks the truth; form drafts are the one deliberate fork, committed explicitly on submit.
Support called it “the rename ghost,” and it had its own tag in the ticket system: 31 reports in one quarter. A user renames a project on the detail page, navigates back, and the list still shows the old name until a hard refresh. The list page fetched projects in an effect and copied them into useState; the detail page did the same with its own fetch; the rename mutation updated the server and the detail page’s local copy. Three components, three private copies of the same server row, zero agreement on when to refresh. The team’s first fix — a global event bus broadcasting “project-changed” — added 400 lines and two new bugs. The real fix was recognizing the category error: project data was never theirs to hold. It belongs to the server; the client holds a cache. They moved reads into a query library keyed by ["projects"] and ["project", id], made the rename mutation invalidate both keys, and deleted every useState that had been impersonating a database. The ghost tag was closed along with 600 lines of synchronization code.
By the end of this lesson you’ll be able to name the owner of any piece of state in your app — and know why getting that wrong is the reason the rename ghost haunted 31 support tickets.
Two kinds of state with opposite physics
Before writing your next useEffect to fetch data, ask: who actually owns this truth? The useState/useReducer/store toolkit from the previous lessons manages client state: truth that originates in the UI — which tab is active, whether the modal is open, the sidebar width. You are its source of truth; it is synchronous, always correct, and dies with the session. Server state is a different substance that happens to flow through the same hooks if you let it: the project list, the user profile, prices. The server owns that truth. Your copy is a cache of someone else’s data — asynchronous, shared with other users who mutate it, and stale the moment it arrives. Caches have a lifecycle client state never has: fetching and refetching, deduplication of concurrent requests, invalidation after mutations, background revalidation, garbage collection of unused entries. A query library (TanStack Query, SWR, RTK Query) is not a “data fetching library” — fetching is one line. It is a cache manager: entries keyed by query key, with staleness tracking, dedupe, focus/interval revalidation, and mutation-driven invalidation. That lifecycle is the actual work, and hand-rolling it per-component is how the rename ghost is born.
The bug class: copying server data into useState
The anti-pattern looks harmless and compiles fine:
// ANTI-PATTERN: forking server truth into local state
function ProjectList() {
const [projects, setProjects] = useState([]);
useEffect(() => {
fetchProjects().then(setProjects); // private copy, frozen at fetch time
}, []);
// ...
}
// Cache-managed: one shared entry, invalidated by mutations
function ProjectListFixed() {
const { data: projects } = useQuery({
queryKey: ["projects"],
queryFn: fetchProjects,
});
// rename elsewhere: queryClient.invalidateQueries({ queryKey: ["projects"] })
}The mechanism of the bug: the moment server data lands in useState, it forks. The component owns a snapshot with no key identifying what it is a copy of, so no mutation can find and invalidate it; two components fetching the same resource hold independent forks that disagree after any write; a refetch races a mutation and the slower response wins, resurrecting old data. Every “fix” — event buses, prop-drilled refresh callbacks, manual refetchAll() — is a hand-rolled, bug-for-bug reimplementation of cache invalidation. The derived variant is subtler and survives reviews: useState(props.user.name) to “initialize” — the initializer runs once on mount, so when the query refetches and props update, the state keeps the stale fork. If you need a transformed view of server data, derive it during render (const sorted = useMemo(...)) — derivation recomputes from fresh truth; copying freezes dead truth.
Form state: the third kind — a deliberate fork
Forms look like a contradiction: an edit form must copy server data into local state — the user types into the draft. The resolution is that a draft is a fork on purpose, with an explicit commit. Divergence from the server is the feature (the user is editing); submit is the commit point that sends the draft back and invalidates the cache. The discipline that keeps deliberate forks safe: the draft is initialized from server data once per editing session, never silently re-synced mid-edit (imagine a background refetch overwriting a half-typed field), and the component remounts when the subject changes — key={project.id} on the form, so editing a different project gets a fresh draft instead of a stale one. Form libraries (react-hook-form, the form tag with actions) are this discipline productized: dirty tracking, reset-on-success, validation on the draft.
▸Why this works
Why is “is it a copy of server truth?” the question, rather than “is it fetched?” Because the failure mode is ownership confusion, not network code. URL state (the current filter in query params) is owned by the router; putting it also in useState forks it the same way. Auth session is server truth cached by your auth provider. The decision generalizes: for every piece of state, name the owner — server, router, form draft, or UI — then store it in that owner’s tool and derive everything else. State with two owners is a bug that hasn’t picked its reproduction steps yet.
The decision table
| The state is… | Owner | Lives in | Never |
|---|---|---|---|
| Fetched, shared, mutable by others (lists, profiles, prices) | Server | Query cache, keyed | Copied into useState |
| UI truth (open modal, active tab, wizard step) | Client | useState / reducer / store (lessons 1–3) | Put in the query cache |
| In-progress edit of server data | Form draft | Form state, keyed by record id, committed on submit | Silently re-synced mid-edit |
| Shareable view config (filters, page, sort) | URL | Router params | Duplicated in useState |
A list page and a detail page each fetch the same project into their own useState. After a rename mutation on the detail page, the list shows the old name until a hard refresh. What is the root cause?
An edit form copies the fetched record into local draft state. By this unit's rules, when is that copy correct — and what two disciplines keep it safe?
Your app displays a user's project list fetched from the API. A rename mutation fires from the detail page. Which approach keeps all views consistent without manual synchronization?
- 01Explain mechanically why copying fetched data into useState produces the stale-after-mutation bug class, and why a query cache fixes it where event buses and refresh callbacks do not.
- 02Lay out the full decision table for where state lives, including the two kinds beyond server/client, and justify the form-draft exception to the no-copy rule.
The unit’s tools — colocation, reducers, external stores — all manage client state: truth that originates in the UI and that you own. Server state only looks similar: the project list and the user profile are the server’s truth, and what your app holds is a cache — asynchronous, shared with other writers, stale from the moment it arrives. Caches have a lifecycle: keyed entries, request deduplication, background revalidation, and above all invalidation after mutations — which is what query libraries actually are: cache managers, not fetch wrappers. The bug class comes from ignoring the boundary: copying fetched data into useState forks the truth into an unkeyed snapshot no mutation can find, so multiple components disagree after a write — the rename ghost — and the seeded variant, useState initialized from props or query data, freezes the fork because initializers run once. Fixes that broadcast refresh events are hand-rolled invalidation; the structural fix is keyed cache entries plus mutations that invalidate the keys they touch, with transformed views derived during render. Forms are the sanctioned exception: a draft is a deliberate fork whose divergence is the feature, made safe by initializing once per session, never re-syncing mid-edit, keying by record id, and committing on submit. The general rule that closes the unit: name the owner — server, router, form draft, or UI — store the state in that owner’s tool, and derive the rest. Now when you see a useEffect that fetches data into useState, you’ll immediately ask: what happens when a mutation fires from another component — and who will invalidate this copy?
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.