Search IOCombats

Search challenges, guides, questions and articles

System DesignTopic 2 of 12BeginnerAug 3, 2026

State Management Architecture

Why local, global, and server state are three different problems, how stale-while-revalidate caching works, and when the URL is the right place to keep state.

frontend-system-designstate-managementcaching

Why It Matters

Most state management problems are not really state management problems. They are categorization problems. A team reaches for a global store because "several components need this," puts server data in it, and then spends the next year fighting bugs that all have the same shape: two parts of the UI disagree about what is true.

Getting the categories right is the whole discipline. Once you know which of the three kinds of state you are holding, the tooling question mostly answers itself - and the bugs that dominate frontend codebases largely stop happening.

Three Categories of State

Local component state

State that belongs to exactly one component and dies with it. Whether a dropdown is open, the current value of an uncontrolled input before submit, whether a tooltip is showing, which step of a wizard is on screen.

The defining property is that no other component needs to know. This is the cheapest state to own, and the default every piece of state should start in. Promotion out of local state should require a reason.

Global client state

State the application owns, that genuinely spans distant parts of the tree, and that does not exist on any server. The active theme, whether the sidebar is collapsed, a multi-step form's accumulated draft, the contents of a notification queue, feature flags resolved once at boot.

The test is ownership: the client is the source of truth. Nobody else can tell you whether the sidebar is collapsed. If you lose this state, nothing is inconsistent - it is just gone.

Server state

Data that lives on a server, that you have a copy of. The user's profile, a product list, an order history, the current cart.

This is the category that breaks things, because it has properties the other two do not:

  • It is shared. Other users, other devices, and background jobs can change it without telling you.
  • It goes stale. The moment it arrives in the browser it starts becoming wrong, and you get no notification when that happens.
  • It is asynchronous. It has loading and error states that local state never has.
  • It can be requested twice. Two components needing the same resource should not produce two network requests.
Diagram
100%
flowchart TB subgraph L["LOCAL - dies with the component"] L1["isDropdownOpen"] L2["hoveredRowIndex"] L3["currentWizardStep"] end subgraph G["GLOBAL CLIENT - app owns the truth"] G1["theme: dark"] G2["sidebarCollapsed"] G3["toastQueue"] end subgraph S["SERVER - you hold a copy, not the truth"] S1["user profile"] S2["product list"] S3["cart contents"] S4["order history"] end S -.->|"stale, shared, async, dedupable"| Cache["Query cache - not a store"] style L fill:#0f172a,stroke:#64748b style G fill:#1e3a5f,stroke:#3b82f6 style S fill:#3f2d1e,stroke:#f59e0b style Cache fill:#3f2d1e,stroke:#f59e0b
visualized byIOCombats

Why Treating Server State as Client State Fails

Put server data in a global store and you have quietly promised something you cannot keep: that the store's value is correct. It is not. It is a snapshot from whenever the fetch resolved.

The bugs that follow are predictable:

Staleness with no expiry. The store has no concept of age. A profile fetched on login is still sitting there an hour later, after the user changed their email on another device. Nothing in the architecture will ever correct it, because a store has no reason to refetch.

Duplicate requests. Three components mount and each dispatches a fetch for the same resource. A store has no request deduplication - it only knows about the value, not about in-flight work.

Hand-rolled async metadata. Because the store only holds a value, teams add isLoading, error, and lastFetchedAt fields per resource, by hand, inconsistently. This is a cache being reimplemented one resource at a time.

Sync bugs after mutation. The user edits their name. Now the server has the new name, the store has the old one, and someone has to remember to update the store too. Forget one place and the UI shows two different names in two different components - the classic symptom.

The fix is not a better store. It is recognizing that server state needs a cache, and a cache has different primitives: keys, freshness windows, invalidation, deduplication, and background refetching.

Stale-While-Revalidate

The pattern that makes remote data feel local. On a read:

  1. If the cache has an entry, return it immediately - even if it is past its freshness window.
  2. If it was stale, fire a background refetch at the same time.
  3. When the fresh response lands, replace the cached value and let subscribed components re-render.

The user never sees a spinner for data they have seen before. They see the previous value instantly, and it silently corrects itself a moment later.

Diagram
100%
sequenceDiagram participant C as Component participant Cache as Query Cache participant API as Server C->>Cache: useQuery(cart) Cache-->>C: cached cart (stale) - renders instantly Note over C: No spinner. User sees data. Cache->>API: GET /cart (background) API-->>Cache: fresh cart Cache->>Cache: replace entry, mark fresh Cache-->>C: silent re-render with fresh data Note over C,API: Second component mounts, same key C->>Cache: useQuery(cart) Cache-->>C: fresh cart - no network call (deduped)
visualized byIOCombats

The trade is explicit: you are choosing perceived speed over guaranteed correctness for a short window. That is right for a product listing and wrong for an account balance shown immediately before a transfer. Freshness windows are a per-resource decision, not a global setting.

Around this core, libraries like React Query and SWR add the invalidation triggers that decide when stale becomes worth refetching: on window focus (the user came back to the tab, data may have moved on), on network reconnect, on an interval for genuinely live data, and explicitly after a mutation that you know invalidated it.

That last one matters most. After a successful mutation you do not manually patch the cache in six places - you invalidate the affected keys and let the cache refetch. One line, and every subscriber converges on the truth.

Cross-Tab Synchronization

Every browser tab is a separate JavaScript context with separate memory. An in-memory store cannot span them, and this is where "the cart says 3 items in one tab and 5 in another" comes from.

BroadcastChannel is the direct solution. Tabs join a named channel and publish messages; every other tab on the same origin receives them.

const channel = new BroadcastChannel('cart');

// After a successful mutation
channel.postMessage({ type: 'CART_UPDATED' });

// In every tab
channel.onmessage = (event) => {
  if (event.data.type === 'CART_UPDATED') {
    queryCache.invalidate(['cart']);
  }
};

Note what the message contains: a signal, not a payload. Broadcasting the new cart contents means the receiving tab trusts a value it did not verify, and two tabs mutating concurrently will race. Broadcasting "this is now invalid" and letting each tab refetch keeps the server as the single arbiter of truth.

The older approach is the storage event, which fires in other tabs when localStorage changes on the same origin. It is convenient because it doubles as persistence, and it has broader legacy support, but it couples the sync mechanism to writing data to disk - which you may not want for anything sensitive, and which is a poor fit when the thing you want to broadcast is not a value at all.

The URL as a State Container

The URL is a state container that ships with four properties for free: it is shareable, bookmarkable, restorable on refresh, and navigable with the back button.

Any state that describes what the user is looking at should live there:

  • Search query and active filters
  • Sort order and pagination
  • Active tab or view mode
  • The ID of an open detail panel or modal

Put a product filter in a store instead, and you have quietly broken things the user expects to work. They cannot send a teammate the filtered view. Refreshing resets it. Back goes to the previous page rather than the previous filter. Each of those is a feature you now have to rebuild - and the URL already did it.

The counter-rule matters too. Ephemeral UI state does not belong in the URL. Whether a dropdown is open is not a location, and putting it there floods the history stack with entries the user never meant to create, so back stops doing what they expect.

There is a real cost to be aware of: URL state changes trigger navigation, which in most frameworks means a re-render of the route and potentially a refetch. Rapidly-changing values - a live-updating search box, a dragged slider - should be debounced before they hit the URL, not written on every keystroke.

A Decision Framework

Ask these in order, and stop at the first yes:

  1. Does it come from the server? Server state. Query cache, keyed by resource. Not a store.
  2. Should it survive a refresh, or be shareable by link? URL, if it describes the view. Persistent storage, if it is a preference.
  3. Do distant components need it, and does the client own the truth? Global client state.
  4. Otherwise local state.

The ordering is the point. Most state that ends up in a global store failed to be tested against the first two questions, and the default should always be the cheapest option that works.

Tradeoffs

OptionProsConsWhen to Use
Local component stateZero coordination cost, no boilerplate, dies cleanly with the componentNot shareable, lost on unmount, prop drilling if it needs to travelEverything, until a concrete requirement forces promotion
Global client storeOne source of truth for app-owned state, easy access from anywhereNo async primitives, no staleness model, becomes a dumping groundTheme, layout, feature flags, cross-cutting draft state
Server-state cacheDeduplication, background refetch, invalidation, loading and error states built inAnother dependency, cache keys need discipline, stale window must be tuned per resourceAny data that originates on a server
URL stateShareable, bookmarkable, survives refresh, back button worksTriggers navigation, limited size, ugly for complex objects, needs debouncingFilters, search, pagination, tabs, open detail IDs
BroadcastChannel syncReal cross-tab consistency, no persistence required, signal-basedSame-origin only, needs a fallback on older browsers, easy to over-broadcastCart, auth session, notification counts, anything visible in multiple tabs

Where This Applies

The server/client split described here starts in the gap that server rendering creates - the server rendered a value the client must then reconcile with, which is why hydration mismatches are usually a state categorization bug. See Rendering Architecture. The cache invalidation strategies sketched here are covered properly in Networking and Data Fetching, and the offline case - queuing mutations while disconnected and replaying them on reconnect - is the hard version of this problem, handled in Offline and PWA Architecture.

In the applied practice problems, this decision dominates Shopping Cart, where the same value is server-owned, optimistically mutated, and visible in multiple tabs at once, and Real-Time Feed, where server state changes without any request from the client at all. It is inverted entirely in Collaborative Document, where the client stops being a cache and becomes a replica.

Advertisement

Frequently Asked Questions

Why is putting server data in Redux considered an anti-pattern?

Because Redux is built for state you own, and server data is state you borrow. A store gives you no answer to the questions that actually matter for remote data - how old is this, is another component already fetching it, should I refetch when the tab regains focus, what happens when two components need the same resource. Teams end up hand-writing loading flags, error flags, timestamps, and deduplication logic per resource, which is a cache implementation built accidentally and badly. A server-state library gives you that cache with the right primitives out of the box.

A user updates their cart in one tab. The other tab still shows the old count. How do you fix this?

The two tabs are separate JavaScript contexts with separate memory, so an in-memory store cannot span them. Use BroadcastChannel to publish a cart-changed message from the mutating tab and have every other tab invalidate its cart cache and refetch. The storage event on localStorage is the older fallback and fires only in other tabs, which is convenient, but it couples you to persisting the data. The important part of the answer is that the fix is a cross-context message, not a bigger store.

When should state live in the URL instead of a store?

When the state describes what the user is looking at rather than what the app is doing. Filters, search queries, pagination, active tab, and open detail IDs all belong in the URL, because that makes the view shareable, bookmarkable, restorable on refresh, and navigable with the browser back button - four properties you would otherwise have to build by hand. Ephemeral UI state like whether a dropdown is open does not belong there, because it would pollute history with entries the user never intended to create.

Explain stale-while-revalidate and the trade it makes.

On a read, the cache returns whatever it has immediately, even if that entry is past its freshness window, and simultaneously kicks off a background refetch. When the fresh response arrives it replaces the cached value and the UI updates silently. The trade is deliberate - the user sees data instantly instead of a spinner, in exchange for a brief window where that data may be wrong. It is the right default for most reads and the wrong default for anything where acting on stale data has consequences, like an account balance before a transfer or remaining ticket inventory at checkout.

How do you decide where a new piece of state should live?

Ask three questions in order. Does it come from the server? Then it is server state and belongs in a query cache keyed by resource, not in a client store. Does it need to survive a refresh or be shareable by link? Then it belongs in the URL or in persistent storage. Is it needed by more than one component that are not close relatives? Then it is global client state. If none of those are true, it stays local - and the default should be local, because every promotion out of a component adds coordination cost that has to be justified.