← All articles
🗂️ ArchitectureIntermediate · 12 min read

Where State Should Live in a Frontend Codebase

Server data in component state. UI state in Redux. Form inputs in global context. Most architectural pain on a growing frontend traces back to one thing: state in the wrong layer.

By Kishan Vaghani · Reviewed by Kajal Pansuriya · Published May 26, 2026

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.

  1. 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(), []).
  2. 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.
  3. 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:

  1. Move server data into a cache library first. This is the largest single win. Identify every place that calls fetch or an Axios wrapper in a useEffect, and migrate them to TanStack Query (or whatever your team picks). Stale-data and double-fetch bugs disappear together.
  2. 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 useState inside the owning component. Rerenders drop, the store shrinks, and the remaining global state becomes easier to reason about.
  3. 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

Author

Kishan Vaghani

Founder & Lead Engineer, ShareCode

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.

Real-time collaboration & CRDTsWebRTC & low-latency mediaFirebase authentication & security rulesNext.js & full-stack JavaScript
Reviewed by

Kajal Pansuriya

Developer Educator, ShareCode

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.

Python fundamentals & teaching beginnersJavaScript debugging & DevToolsCoding-interview preparationClean code & code review

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