Beyond useState: Architecting Global State in Modern React
useState is the first tool every React developer reaches for — and rightfully so. It's co-located, simple, and perfect for isolated UI interactions. But somewhere between your fifth prop-drilling session and your third mysterious Context re-render, a pattern becomes clear: local state doesn't scale.
This article is for developers who've hit that wall. We'll examine why the Context API is a deceptive shortcut, compare modern state libraries honestly, and walk through a production-grade pattern for structuring global state in a mid-to-large React app.
The Two Warning Signs You've Outgrown Local State
1. Prop Drilling Past Two Levels
When you're passing a prop through components that have zero interest in it — just to get data to a deeply nested child — that's prop drilling. It tightly couples your component tree, turns every refactor into a scavenger hunt, and makes code review a "where does this come from?" investigation.
2. Shared Mutations Between Sibling Subtrees
When two disconnected branches of your UI need to read and update the same piece of data, lifting state to the nearest common ancestor works — until the ancestor is the root component.
Why the Context API Isn't the Solution
React Context feels like the answer. It's built-in, it eliminates prop drilling, and it works beautifully for slowly-changing values like themes or locale. But here's the trap:
// ❌ This looks clean. It has a hidden landmine.
const AppContext = createContext<AppState | null>(null)
export function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [cart, setCart] = useState<CartItem[]>([])
const [theme, setTheme] = useState<'light' | 'dark'>('light')
// 🚨 When `cart` changes, EVERY consumer of this context re-renders.
// That includes <Header />, which only reads `user`.
const value = { user, setUser, cart, setCart, theme, setTheme }
return <AppContext.Provider value={value}>{children}</AppContext.Provider>
}
React Context does not have a fine-grained subscription mechanism. When the value object changes — any part of it — every component calling useContext(AppContext) will re-render.
The common workaround is splitting into multiple contexts: UserContext, CartContext, ThemeContext. But now you're managing five nested providers and a "context layer" that's arguably more complex than using a proper state library from the start.
Choosing the Right Tool
| Library | Bundle (gzip) | Mental Model | Best For |
|---|---|---|---|
| Zustand | ~1.1 KB | Flat store + co-located actions | Most production apps — the sweet spot |
| Jotai | ~3.1 KB | Atomic bottom-up state | Fine-grained subscriptions, derived state |
| Redux Toolkit | ~11 KB | Slices / reducers / actions | Large teams, audit trails, complex async flows |
| Valtio | ~2.7 KB | Proxy-based mutable state | Vue-like feel in a React codebase |
For most mid-to-large apps, Zustand is the pragmatic choice. It's tiny, has no boilerplate, integrates with Redux DevTools, supports middleware, and is trivially easy to test.
Building a Zustand Store
// store/useUserStore.ts
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
interface User {
id: string
name: string
email: string
role: 'admin' | 'member' | 'viewer'
}
interface UserState {
// State
user: User | null
isAuthenticated: boolean
// Actions — co-located with the state they own
setUser: (user: User) => void
clearUser: () => void
updateRole: (role: User['role']) => void
}
export const useUserStore = create<UserState>()(
devtools(
persist(
(set, get) => ({
user: null,
isAuthenticated: false,
setUser: (user) =>
// Third arg is the DevTools action label — invaluable for debugging
set({ user, isAuthenticated: true }, false, 'user/setUser'),
clearUser: () =>
set({ user: null, isAuthenticated: false }, false, 'user/clearUser'),
updateRole: (role) => {
// `get` accesses current state without subscribing
const { user } = get()
if (!user) return
set({ user: { ...user, role } }, false, 'user/updateRole')
},
}),
{ name: 'user-storage' } // Automatically syncs to localStorage
),
{ name: 'UserStore' }
)
)
Selectors: The Key to Performance
Components subscribe only to the slice of state they need. Zustand uses strict equality by default, so a component re-renders only when its subscribed slice actually changed — not when unrelated state updates:
// ✅ Only re-renders when user.name changes — nothing else triggers this
function WelcomeBanner() {
const name = useUserStore((state) => state.user?.name)
return <h1>Welcome back, {name ?? 'Guest'}</h1>
}
// ✅ Only re-renders when isAuthenticated flips between true/false
function NavLinks() {
const isAuthenticated = useUserStore((state) => state.isAuthenticated)
return isAuthenticated ? <AuthLinks /> : <PublicLinks />
}
// For object selections, use `useShallow` to prevent reference re-renders
import { useShallow } from 'zustand/react/shallow'
function ProfileCard() {
const { name, email, role } = useUserStore(
useShallow((state) => ({
name: state.user?.name,
email: state.user?.email,
role: state.user?.role,
}))
)
}
The Slice Pattern for Large Apps
When a single store grows beyond 3–4 features, split it into slices that are composed at the root:
// store/slices/cartSlice.ts
import type { StateCreator } from 'zustand'
import type { RootStore } from '../useRootStore'
interface CartItem {
id: string
productId: string
quantity: number
price: number
}
export interface CartSlice {
cartItems: CartItem[]
addToCart: (item: CartItem) => void
removeFromCart: (id: string) => void
clearCart: () => void
cartTotal: () => number // Computed value as a method
}
export const createCartSlice: StateCreator<RootStore, [], [], CartSlice> = (set, get) => ({
cartItems: [],
addToCart: (item) =>
set(
(state) => ({ cartItems: [...state.cartItems, item] }),
false,
'cart/addItem'
),
removeFromCart: (id) =>
set(
(state) => ({ cartItems: state.cartItems.filter((i) => i.id !== id) }),
false,
'cart/removeItem'
),
clearCart: () => set({ cartItems: [] }, false, 'cart/clear'),
// Method pattern avoids stale closure issues with computed values
cartTotal: () =>
get().cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0),
})
// store/useRootStore.ts — Compose all slices into one store
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { createCartSlice, type CartSlice } from './slices/cartSlice'
import { createUserSlice, type UserSlice } from './slices/userSlice'
import { createUISlice, type UISlice } from './slices/uiSlice'
export type RootStore = CartSlice & UserSlice & UISlice
export const useRootStore = create<RootStore>()(
devtools(
(...args) => ({
...createCartSlice(...args),
...createUserSlice(...args),
...createUISlice(...args),
}),
{ name: 'AppStore' }
)
)
The Global vs. Server State Distinction
The most common architectural mistake is storing server data in global state. API responses belong in a query cache, not in Zustand.
| Data Type | Right Tool | Examples |
|---|---|---|
| Server state | TanStack Query / SWR | API responses, paginated lists, user profiles from /api/me |
| Global UI state | Zustand | Auth user object, theme, cart contents, notification queue |
| Local UI state | useState | Modal open/close, form inputs, accordion toggle |
| URL state | React Router / nuqs | Search filters, pagination, active tab |
Recommended Folder Structure
src/
├── store/
│ ├── useRootStore.ts # Combined store entry point
│ └── slices/
│ ├── userSlice.ts
│ ├── cartSlice.ts
│ └── uiSlice.ts
├── hooks/
│ ├── useAuth.ts # Wraps useRootStore with auth-specific logic
│ └── useCart.ts # Exposes cart actions + computed values
Conclusion
The jump from useState to global state management isn't about picking the trendiest library — it's about understanding the topology of your data. Keep server data in a query cache, local interaction state in useState, and only promote state to a global store when it genuinely needs to be shared across disconnected parts of your UI. Zustand with the slice pattern gives you that structure without the overhead of Redux or the performance pitfalls of a monolithic Context.