State Architecture: A Decision Framework for Where State Belongs

Learn to classify every piece of state in a React application by kind and by scope, and use a practical decision framework to choose where each piece should live.

The single most consequential decision in a non-trivial React app is not which hook to use, but where a given piece of state should live and who should own it. Get this wrong and you end up with prop drilling, unnecessary re-renders, state that goes stale, or the same piece of truth duplicated in two places that silently drift apart. This lesson treats that decision as a repeatable process, not a matter of taste.

The categories of state

Not all state is the same kind of thing, and conflating the categories is where most architecture problems start. Local state lives and dies with one component and nothing else needs it. Lifted state is local state that got pulled up to the nearest common ancestor of two or more components that need to read or change it together. Shared/global state is read or written from many unrelated parts of the tree — distributed via Context or an external store rather than lifted, because lifting to the app root would mean threading it through everything.

Server state is data that actually lives on a server and is only cached on the client — it has its own concerns (staleness, refetching, background revalidation) that plain useState/useReducer don't address, which is why dedicated libraries (React Query, SWR, RTK Query) exist specifically for it rather than treating it like ordinary client state. URL state is state that belongs in the address bar — the current route, query params, a selected tab — because it should survive a refresh and be shareable/bookmarkable as a link. Form state is the values, touched/dirty flags, and validation errors of an in-progress form, often ephemeral and local unless the form spans multiple steps.

Derived state is not stored at all — it's computed on the fly from other state during render (a filtered list, a total price, a formatted date). Persistent state is state that must survive a page reload (in localStorage, IndexedDB, or a cookie) rather than resetting on refresh. A state machine is a formalization of state as a small enumerated set of statuses with explicit allowed transitions between them (idle -> loading -> success | error), useful once a piece of state has more than two or three interdependent boolean flags describing it.

State normalization means storing entity data (like a list of users or posts) as an id-keyed lookup map rather than a nested array of objects, so updating one entity doesn't require finding and replacing it inside a deep array — this matters once you have relational data referenced from multiple places. Optimistic state means updating the UI immediately in response to a user action, before the server has confirmed it, then rolling back if the request fails — used to make an app feel instant despite network latency. Cache invalidation is the discipline of knowing when previously fetched server state is stale and must be refetched (after a mutation, after a time window, or on demand) — 'cache invalidation' is famously one of the two hard problems in computer science for a reason.

A practical decision framework: where does this state belong?

Work through these questions in order for any new piece of state; the first question that gives a clear answer usually settles it. Treat it as a checklist you run once per piece of state, not a one-time architectural decision for the whole app — a single page routinely has different pieces of state landing in different categories.

The order matters: it's deliberately structured to rule out the most consequential categories (server state, URL state) first, because getting those two wrong is the most common and most expensive mistake — treating server data as if it were ordinary client state, or losing a filter/selection on refresh because it lived only in useState.

• 1. Does this state mirror something that actually lives on a server? If yes, it's server state — reach for a data-fetching library (React Query, SWR) rather than useState/useEffect, so you get caching, refetching, and invalidation for free instead of reimplementing them by hand. • 2. Should this survive a page refresh or be shareable as a link? If yes and it's navigational (a route, a filter, a selected tab, a search query), put it in the URL (route params or query string). If yes and it's not navigational (a draft, a user preference), put it in localStorage/IndexedDB as persistent state. • 3. Is this read or written by only one component (and nothing it renders needs to bypass it)? If yes, it's local state — useState or useReducer inside that component, nothing more. • 4. Is this read or written by a small, well-defined group of sibling/parent components? If yes, lift it to their nearest common ancestor and pass it down as props (plus a setter/dispatch) — this is still simpler and more traceable than Context for a bounded group. • 5. Is this read by many components scattered across different, unrelated parts of the tree, and does it change infrequently? If yes, it's a Context candidate — theme, auth, locale, feature flags. • 6. Is this read by many components, changes frequently, and/or needs fine-grained subscriptions so unrelated parts of the UI don't re-render on every update? If yes, reach for a dedicated external state store (Zustand, Redux, Jotai) instead of Context, specifically because of its selector-based, non-re-render-everything subscription model. • 7. Can this value be computed from state that already exists elsewhere? If yes, don't store it at all — compute it as derived state during render (or with useMemo if the computation is expensive), so it can never drift out of sync with its source.

Applying the framework: a worked example

Consider a product listing page with a search box, a category filter, a sortable results list fetched from an API, and a shopping cart icon in the header. Walking the framework: the product data itself is server state (React Query, keyed by the current search/filter/sort params, so changing any of them naturally triggers a refetch with proper caching). The search text, category filter, and sort order are all navigational and should be shareable/bookmarkable, so they belong in the URL as query params, not in useState — a useState-based filter is lost the moment the page is refreshed or the link is shared.

The 'is this item hovered' or 'is this dropdown open' flags are purely local to their own small components — plain useState, nothing more. The cart's contents are read by both the product cards (add-to-cart buttons) and the header icon (item count), which are unrelated parts of the tree, and the cart changes moderately often — this is a legitimate Context (or small external store) candidate, likely backed by a useReducer for the add/remove/update-quantity transition logic, per the previous lesson.

The displayed total price on the cart icon is derived state — computed from the cart's item list on every render (or memoized if the list is large), never stored as its own useState that could fall out of sync with the items it's supposed to summarize. Notice that in this single page, five or six different categories of state are all present and each one lands in a different place — that diversity, not any single 'right answer,' is the normal shape of a real app's state architecture.

The decision framework as a quick-reference checklist

This is deliberately a checklist, not a flowchart with one 'correct' terminal node — a single page legitimately has state landing in several different categories at once, as the worked example shows.

// Ask in order — the first "yes" usually settles it:
// 1. Mirrors server data?            -> server state (React Query / SWR)
// 2. Should survive refresh / be shareable as a link?
//      -> navigational (route, filter, tab)  -> URL state
//      -> not navigational (draft, prefs)     -> persistent state (localStorage)
// 3. Used by exactly one component?  -> local state (useState / useReducer)
// 4. Used by a small sibling/parent group? -> lifted state (props down from common ancestor)
// 5. Used widely, changes rarely?    -> Context (theme, auth, locale, flags)
// 6. Used widely, changes often, needs fine-grained subscriptions?
//                                     -> external store (Zustand / Redux / Jotai)
// 7. Computable from other state?    -> derived state (compute in render / useMemo), don't store it

Code Examples

The 'wrong' version introduces a useState + useEffect pair purely to keep a copy of something that's fully determined by items — it adds an extra render cycle (state starts at 0, then the effect updates it a moment later) and a way for total to fall out of sync if the effect's dependency array is ever wrong. The 'right' version can never be out of sync, because it's recomputed fresh every render.

const total = items.reduce(...); // no useState needed Common mistake: Reaching for useState + useEffect to 'sync' a value that's simply a computation of existing state.

Because the selected category lives in the URL's query string rather than a plain useState, refreshing the page, bookmarking it, or sharing the link all preserve the exact filtered view the user was looking at — something a component-local useState could never do. Expected result: Visiting /products?category=electronics shows the electronics filter pre-applied, even on a fresh page load.

// Wrong: storing a value that's entirely computable from other state
function Cart({ items }: { items: CartItem[] }) {
  const [total, setTotal] = useState(0);
  useEffect(() => {
    setTotal(items.reduce((sum, i) => sum + i.price * i.qty, 0));
  }, [items]);
  return <div>Total: {total}</div>;
}

// Right: compute it directly during render
function Cart({ items }: { items: CartItem[] }) {
  const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
  return <div>Total: {total}</div>;
}
function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams(); // e.g. react-router
  const category = searchParams.get('category') ?? 'all';

  function setCategory(next: string) {
    setSearchParams({ category: next });
  }

  const { data: products } = useFetch<Product[]>(`/api/products?category=${category}`);
  return (
    <div>
      <CategoryFilter value={category} onChange={setCategory} />
      <ProductGrid products={products ?? []} />
    </div>
  );
}

State scope, from narrowest to widest

Scope (how widely state is needed) and kind (server / URL / form / derived / persistent) are two separate axes — a real app's state architecture places different pieces at different points on both.

Local (one component)
   |  needed by siblings too?
   v
Lifted (nearest common ancestor, passed via props)
   |  needed widely, rarely changes?          needed widely, changes often?
   v                                           v
Context (theme, auth, locale)          External store (Zustand/Redux) or Server state (React Query)

(Meanwhile, orthogonal to all of the above: URL state for navigational values,
 persistent state for anything that must survive a refresh,
 and derived state for anything computable from what's already stored.)

Important Rules

Ask 'does this already exist somewhere else and can be computed?' before creating any new piece of state — derived state should never be duplicated into its own useState.

Treat server-fetched data as server state with a dedicated library, not as plain useState populated inside a useEffect — you lose caching, deduplication, and invalidation otherwise.

Put anything navigational (filters, tabs, search queries, the current record being viewed) in the URL, not in component state, so it survives refresh and is shareable.

Default to the narrowest possible scope (local, then lifted) and only escalate to Context or an external store once a concrete cross-tree sharing need appears — don't pre-emptively globalize state 'just in case.'

Normalize entity data (id-keyed maps) once the same entity is referenced from more than one place in the UI, to avoid update-and-search-through-nested-arrays bugs.

How production teams apply this framework

Teams that keep state architecture healthy over time typically enforce a rule like: 'server data goes through React Query (or equivalent), navigational state goes in the URL, and Context/external stores are reserved for state with a genuine cross-tree sharing need' — written down as a short guideline so every engineer makes the same category judgment instead of defaulting to 'put everything in one big global store' or 'put everything in local useState and prop-drill it,' both of which degrade badly as an app grows past a handful of screens.

// Copying a prop into local state 'just in case', then letting it drift function UserBadge({ user }: { user: User }) { const [localUser, setLocalUser] = useState(user); // now there are two sources of truth return <span>{localUser.name}</span>; } function UserBadge({ user }: { user: User }) { return <span>{user.name}</span>; // just read the prop directly, no local copy }

Copying a prop into useState creates a second source of truth that does not automatically update when the prop changes on a later render — the component silently shows stale data. Read props directly unless you have a specific, well-understood reason to fork local editable state from them (like an uncontrolled form field with a default value).

// Treating fetched data like ordinary component state function Profile({ userId }: { userId: string }) { const [user, setUser] = useState<User | null>(null); useEffect(() => { fetch(/api/users/${userId}).then((r) => r.json()).then(setUser); }, [userId]); // no caching, no dedup, no invalidation, no stale-while-revalidate return <div>{user?.name}</div>; }

// Treating it as server state with a dedicated tool function Profile({ userId }: { userId: string }) { const { data: user } = useQuery(['user', userId], () => fetchUser(userId)); return <div>{user?.name}</div>; }

The hand-rolled version refetches on every mount even if the data was just fetched a second ago by another component, has no way to invalidate itself when the user is updated elsewhere, and races if userId changes quickly. A server-state library handles caching, deduplication, and invalidation as first-class concerns instead of being reimplemented ad hoc per component.

[multiple-choice] According to the decision framework, where should a value that can be fully computed from existing state (like a cart total from a list of items) live? A. In its own useState, updated by a useEffect watching the source state B. In Context, so any component can read it C. Nowhere as stored state — compute it during render (optionally with useMemo) D. In the URL as a query parameter

C. Nowhere as stored state — compute it during render (optionally with useMemo) — Derived state should be computed fresh during render, not stored redundantly — storing it invites the computed value to fall out of sync with the state it's derived from.

[multiple-choice] Why should a navigational filter (like a selected category) typically live in the URL rather than in useState? A. URL state renders faster than component state B. So the filtered view survives a page refresh and can be shared or bookmarked as a link C. useState cannot hold string values D. It's required by React Router specifically, not a general principle

B. So the filtered view survives a page refresh and can be shared or bookmarked as a link — Component state resets on refresh and can't be captured in a shareable link; URL state (route params or query string) persists across reloads and makes the exact view linkable.

[true-false] True or false: treating server-fetched data as ordinary useState/useEffect-managed state gives you the same caching and invalidation behavior as a dedicated server-state library, just with more code. A. True B. False

B. False — It does not — a hand-rolled useState/useEffect fetch has no caching, request deduplication, background revalidation, or invalidation semantics by default; those are exactly the concerns dedicated server-state libraries are built to solve, not just boilerplate they happen to reduce.

A dashboard has: (a) the list of the user's saved reports, fetched from an API; (b) which report is currently selected, reflected in the URL as /dashboard/:reportId; (c) whether a particular row's dropdown menu is open; (d) the user's saved 'dark mode' preference, which should persist across sessions. Classify each of the four and say where it belongs.

(a) Server state — a data-fetching library like React Query. (b) URL state — a route param, not component state, so the selected report is bookmarkable/shareable and survives refresh. (c) Local state — useState inside that row's own component, needed nowhere else. (d) Persistent state — localStorage (or a cookie), since it must survive a full session/reload, likely also distributed via Context if many components need to read the current theme.

Apply the seven-question framework to each one independently — they don't all land in the same category.