Rethinking State in Large React Applications
Global state libraries don't fail because they're badly built. They fail because most teams reach for one before asking where the state actually lives.
We've rebuilt state management on three different client codebases now, each time inheriting a global store with hundreds of keys, half of them dead. Every time, the fix wasn't a better library. It was asking a question nobody had asked at the start: where does this state actually need to live?
The default that causes the mess
Most teams default new state to a global store because it's the path of least resistance — one useSelector away from anywhere in the app. That convenience has a cost that compounds silently: every consumer of global state re-renders on changes it doesn't care about, and every piece of state becomes reachable from everywhere, which means nothing can be safely deleted without a full audit.
A simpler default: state lives where it's read
We now default every new piece of state to the narrowest scope that can hold it, and only promote it upward when a second, unrelated consumer genuinely needs it:
// Before: reflexively global
const isFilterPanelOpen = useAppSelector((s) => s.ui.filterPanelOpen);
// After: scoped to where it's actually used
function FilterPanel() {
const [isOpen, setIsOpen] = useState(false);
// ...
}
This sounds obvious written down. In practice it requires actively resisting the instinct to reach for the global store, because the global store is always the easier immediate choice — it just isn't the easier choice six months later.
A good heuristic: if you can't name the second component that will read this state, it doesn't belong in global state yet. Promote it when the second consumer actually shows up, not preemptively.
Where global state still earns its place
We didn't eliminate global state — we narrowed what qualifies for it. Session identity, feature flags, and genuinely cross-cutting UI state (like an app-wide theme) still live globally, because they're read from dozens of unrelated places by design. The difference is that promotion to global state is now a deliberate decision made once a second consumer appears, not a default made on day one.
Store key count and re-render frequency before and after the rescope
Server state is not client state
The other half of the fix was separating server-derived data from client UI state entirely. Data fetched from an API — a user's profile, a list of projects — isn't really "state" in the traditional sense; it's a cache with its own lifecycle (staleness, refetching, invalidation). Treating it as regular global state meant we were hand-rolling cache invalidation logic that a dedicated data-fetching layer already solves correctly.
"Half of what teams call 'state management problems' are actually cache invalidation problems wearing a different name."
What it looked like six months later
The store that started with 340 keys now has 40, all genuinely cross-cutting. Re-render counts on our busiest screens dropped by more than half, not because we optimized rendering, but because most components stopped subscribing to state that was never theirs to watch.
One email, every other Friday
Engineering notes, new articles, and things we learned the hard way — no marketing, ever.
Read by 2,400+ engineers. Unsubscribe anytime.
Let's talk
Let's build what's next.
If any of this sounds like the kind of work you want done on your systems, our engineers are the ones who'll actually do it.
