The single most common architectural mistake on growing frontend codebases is putting state in the wrong layer. Server data ends up in component state and goes stale. UI state ends up in Redux and becomes hard to remove. Form inputs end up in global context and survive across navigations because nobody resets them. The fix is not a library choice. The fix is to name the three layers and put each piece of state in the one it actually belongs to.
The three layers
1. Server state
Server state is anything the backend owns the source of truth for — users, orders, comments, search results. The frontend caches it temporarily, but the real value lives on the server and can change while your tab is open. Server state belongs in a cache library: TanStack Query, SWR, RTK Query, Apollo. These libraries handle the four hard problems for free: deduplication of in-flight requests, invalidation when the data changes, retries on transient failure, and refetch on focus or reconnect.
// Server state — owned by the backend, cached on the client
const { data: user, isLoading } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
});2. Global client state
Global client state is data the frontend owns that is needed in more than one part of the tree — auth status, current theme, feature flags, the stack of open modals, the user's preferred locale. It survives across page navigations and does not belong to any single component. This layer should be small. Most apps have five or fewer things at this layer; if yours has more, some of it is probably server state in disguise.
// Global client state — small, frontend-owned, shared across the tree
const useAuth = create((set) => ({
user: null,
setUser: (user) => set({ user }),
signOut: () => set({ user: null }),
}));3. Local component state
Local component state is everything that belongs to one component and its immediate children — the open/closed state of a dropdown, the current value of a text input, whether a row is hovered, the tab index of a tab strip. It lives in useState or useReducer. It is never elevated unless multiple unrelated components actually need it. "Multiple unrelated components might need it someday" is not a reason to elevate.
A three-question rubric
For any piece of state you are about to introduce, ask three questions in order. The first one that returns yes tells you the layer.
- Does the backend own the source of truth? If yes, this is server state. Put it in your cache library. The cache handles invalidation; you do not write
useEffect(() => fetchUser(), []). - Does the data exist before the user lands on this page, and survive across navigations? If yes, this is global client state. Auth, theme, and feature flags pass this test. A form input does not.
- Would this make sense as a prop? If yes, it is local state. Keep it in the component or drill it one level. If it would need to be drilled through five layers, lift it to the nearest common parent — not to a global store.
What goes wrong when you get it wrong
The four failures below are all the same mistake — state in the wrong layer — but each one looks different at the surface, and spotting them is most of the work.
Server data in Redux (or Zustand)
Symptom: the user object on screen is stale because nobody invalidates the store after an edit. The team writes manual "refresh" actions after every mutation, gets some of them wrong, and ships stale data. A cache library would have invalidated and refetched for free.
Form inputs in global context
Symptom: a user fills in half a form, navigates away, comes back, and the fields are still populated with their old values — except some of them, because some fields reset and some did not, depending on which developer added each input. Form state belongs inside the form. It should be reset by unmounting, not by remembering.
Modal state global
Symptom: opening a modal becomes an action dispatched to a global store, and closing one component's modal accidentally closes another's. Cross-component modal interactions turn into routing logic. Modals should usually be controlled by the component that owns the trigger, or — if they are truly global — mounted by a single coordinator that nobody else talks to directly.
Local state elevated "just in case"
Symptom: every keystroke in a search input causes the entire app to rerender, because the input value sits in a global store and every connected component subscribes to the whole slice. Local state stays local until proven otherwise. Elevation is reversible; the performance cost of preemptive elevation is not.
The refactor path for a drifted codebase
A codebase that has drifted into the wrong layers usually has all three failure modes at once. The refactor is highest-leverage when done in order:
- Move server data into a cache library first. This is the largest single win. Identify every place that calls
fetchor an Axios wrapper in auseEffect, and migrate them to TanStack Query (or whatever your team picks). Stale-data and double-fetch bugs disappear together. - Push local-ish state back down. Find pieces in the global store that are only read by one or two components and move them to
useStateinside the owning component. Rerenders drop, the store shrinks, and the remaining global state becomes easier to reason about. - Trim the global store last. Once server data has left and local-ish state has gone home, what remains is the actual global client state — usually five things, all of them small and slow-changing. At that point, choosing between Context, Zustand, Jotai, or Redux is a taste call and not an architectural one.
Why the layers matter more than the library
Teams argue about Redux vs Zustand, Context vs Jotai, TanStack Query vs SWR. These arguments are largely a distraction. The layers — server state, global client state, local component state — are stable across every library that implements them. A codebase that gets the layers right migrates between libraries in a week. A codebase that gets them wrong stays painful no matter which library is in the dependency tree.
The habit that compounds
Every time you reach for useState, ask the three questions out loud. Every time a PR adds something to the global store, ask whether the backend owns the truth and whether the data survives across navigations. State ownership is one of the few architectural decisions that survives library migrations — get it right once and it pays back across years and refactors.
Related reading
State ownership sits next to component design and data fetching in a healthy frontend. The component-boundaries post covers the surface above this layer, and the data-fetching post covers the server-state layer in detail. For where this layer intersects with the App Router boundary, the server-vs-client-components piece is the next read, and the broader frontend-architecture topic collects all four.
About the writers
Founder of ShareCode. Writes the engineering deep-dives on this site — WebRTC, Firebase Auth, real-time sync, and the production patterns behind the editor itself.
More from Kishan
Developer educator at ShareCode. Writes the tutorial track — Python, JavaScript debugging, coding-interview prep, and the everyday code-quality habits that hold up in real codebases.
More from Kajal
Auditing where your state lives?
Paste a snapshot of your global store and a few representative useEffect-based fetch sites into a code space, share the link with a teammate, and walk through the three-question rubric together. The first pass usually finds two or three pieces of state that obviously belong in a different layer.
Open a code space →