In the React ecosystem, we’ve spent years debating which state management library to use — Redux, MobX, Recoil, Zustand, Jotai, XState, and the ever-humble React Context.
But the real architectural question we often forget is far more fundamental:
- Where should state live?
- How far should it travel?
- And how do we prevent state from becoming a silent source of UI slowdown?
Let's discuss in depth on an unglamorous yet powerful topic that every UI architect should obsess about:
State Boundaries — The Most Important Concept in Scalable React Apps
State boundaries define how far a piece of state is allowed to influence the UI.
If your components re-render “too much,” lag under load, or behave unpredictably, the root cause 90% of the time is:
Your state boundaries are broken.
Let’s understand this architecturally.
1. Why State Boundaries Matter More Than the Library You Choose
Most engineers think state problems are library problems:
“Redux is too verbose”
“Context causes re-renders”
“Zustand feels easier”
“useState is enough for everything”
“React Query replaces global state”
…but performance issues rarely stem from the library. They stem from incorrect scoping.
Poor state boundaries → excess re-renders → wasted CPU → janky UI.
A simple example:
// ❌ Anti-pattern
<App>
<Navbar user={user} />
<Sidebar user={user} />
<Dashboard user={user} />
</App>
You’ve just created a gigantic state boundary — one tiny state change in user will re-render half the application.
Now imagine 200 components relying on the same global state.
This is how “React feels slow” stories are born.
2. The Architect’s Rule: “State Should Be as Local as Possible”
React is built on the idea of locality of state.
A simple rule:
Move state down until it stops mattering to everyone above it.
Move shared state up only when two siblings need it.
Micro Example:
A toggle button controlling its own UI.
function Toggle() {
const [on, setOn] = useState(false);
return <button onClick={() => setOn(!on)}>{on ? "ON" : "OFF"}</button>
}
Perfectly local. No need for global state. No need for context. Zero re-renders outside the component.
Macro Example:
A user object needed by 15 components across the page.
Context or Zustand makes sense here.
But what if only two components need the user’s theme preference?
Then create a smaller local boundary:
User State (global)
├── Theme Preference (local to UI shell)
└── User Metadata (local to specific feature)
Architectures break when everything is global “just in case.”
3. The Hidden Performance Killer: “Derived State Leaks”
A subtle but deadly anti-pattern:
const user = useUserStore(state => state.user);
const isAdmin = user.role === 'admin'; `// ❌ derived but inside` global boundary
This component now re-renders whenever anything inside user changes — phone number, profile pic, status, anything.
Fix: keep derived state as close to its usage as possible.
const isAdmin = useUserStore(state => state.user.role === 'admin'); // ✔ only re-renders when role changes
You've just reduced re-renders by 90%.
4. State Machines: Creating Predictable Boundaries
One of the cleanest ways to define strict state boundaries is using UI state machines (XState or custom).
Example: A checkout UI.
const checkoutMachine = {
id: "checkout",
initial: "cart",
states: {
cart: { on: { NEXT: "address" }},
address: { on: { NEXT: "payment", BACK: "cart" }},
payment: { on: { NEXT: "review", BACK: "address" }},
review: { on: { SUBMIT: "complete", BACK: "payment" }},
complete: {}
}
}
Here, each boundary is a strict, predictable “mode.”
Why is this beneficial?
- UI cannot accidentally access out-of-scope state
- Reduces invalid transitions
- Less conditional UI logic (“if cart then… else if payment then…”)
- Fewer re-renders since each state is independent
5. How to Design State Boundaries in a Real Project (Architect’s Checklist)
✔ Step 1:
Identify State Types
- Local UI state (modals, text inputs)
- Server state (React Query / SWR)
- Global app state (theme, auth, settings)
- Ephemeral UI logic (hover, focus, scroll)
✔ Step 2: Assign State Ownership
- Local state → useState, useReducer
- Shared UI state → Context (carefully)
- Large-scale global logic → Zustand / Redux
- Data fetching → React Query
- Flow control → state machines
✔ Step 3: Prevent State Leakage
- Map state slices with selectors
- Avoid passing entire objects down props
- Memoize expensive selectors
- Use “compound component patterns” for local boundaries
✔ Step 4: Measure before optimizing
Use:
- React DevTools Profiler
- Flamegraph
- Why Did You Render (WDR)
- Browser performance panel
If a boundary causes 30+ rerenders, consider splitting it.
- Example: A Poorly Architected Component Tree
<App>
<UserProvider> // global and huge
<Layout>
<Sidebar />
<Dashboard />
<Notifications />
<Search />
<Profile />
</Layout>
</UserProvider>
</App>
If user changes → everything re-renders.
- Same App With Better State Boundaries
<App>
<AuthProvider> // only login/logout
<Layout>
<UserPreferencesProvider> // theme, language
<Sidebar />
<Profile />
</UserPreferencesProvider>
<Dashboard /> // fetches its own data
<NotificationProvider>
<Notifications />
</NotificationProvider>
<Search /> // local debounced state
</Layout>
</AuthProvider>
</App>
Each provider now has:
- isolated re-renders
- purpose-specific state
- predictable ownership
- This is real UI architecture.
- The Takeaway (And What Most Engineers Miss)
React doesn’t get slow because:
“React is slow”
“Context is bad”
“Redux is heavy”
React gets slow because state boundaries are drawn carelessly.
A UI Architect’s job isn’t to decide between Redux or Zustand.
It is to decide where state should live and where it shouldn’t.
When boundaries are clean:
Features become independent
Re-renders shrink
Debugging becomes trivial
Onboarding becomes easier
Performance becomes natural—not an afterthought
Final Thoughts
In a world obsessed with new React libraries, one of the most traditional—and most overlooked—topics remains the true pillar of scalable UI architecture:
State Boundaries decide whether your React app survives scale or collapses under it.
If you master this, tools become replaceable.
Architecture becomes future-proof.
Teams become faster.
And your React UI becomes predictable, fast, and delightful.
Top comments (0)