Back to Articles

Beyond useState: Architecting Global State in Modern React

August 3, 2026

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

LibraryBundle (gzip)Mental ModelBest For
Zustand~1.1 KBFlat store + co-located actionsMost production apps — the sweet spot
Jotai~3.1 KBAtomic bottom-up stateFine-grained subscriptions, derived state
Redux Toolkit~11 KBSlices / reducers / actionsLarge teams, audit trails, complex async flows
Valtio~2.7 KBProxy-based mutable stateVue-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 TypeRight ToolExamples
Server stateTanStack Query / SWRAPI responses, paginated lists, user profiles from /api/me
Global UI stateZustandAuth user object, theme, cart contents, notification queue
Local UI stateuseStateModal open/close, form inputs, accordion toggle
URL stateReact Router / nuqsSearch 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.

Thanks for reading. Browse more articles →