Here's a conversation I've had more than once.
A developer is building a React app. It starts simple, a few components, some local state, everything in useState. Then the app grows. Props start getting passed down two levels, then three, then five. Someone suggests Context. Someone else says "just use Redux." A third person has heard of Zustand.
Three hours later, the team is arguing about state management instead of building the product.
The problem isn't that there are too many options. The problem is that most teams pick a tool based on what they've heard of, not based on what problem they're actually solving.
This article gives you a decision framework, and a single example that grows with you, so the next time this conversation comes up, you know exactly what to reach for and why.
TL;DR
-
useStateis not a "beginner tool", it's the right tool for local, component-scoped state, at any level of experience. - Context API solves prop drilling, not state management. Using it as a global store causes re-render problems that are hard to debug.
- Zustand is the pragmatic choice for shared global state in most real-world apps, minimal boilerplate, excellent performance, no ceremony.
- Redux still has a place, but that place is narrower than most teams think.
Table of Contents
- The Example We'll Use Throughout
- Stage 1: useState: Local State Done Right
- Stage 2: Prop Drilling and When It Actually Becomes a Problem
- Stage 3: Context API: What It's Good For and Where It Breaks
- Stage 4: Zustand: Shared State Without the Ceremony
- Where Redux Fits (and Where It Doesn't)
- The Decision Tree
- Final Thoughts
The Example We'll Use Throughout
We're building a shopping cart for an e-commerce app.
The features we'll implement:
- Add and remove products from the cart
- Show the cart item count in the navbar
- Show the full cart contents in a sidebar
Here's the component tree we're working with:
App
├── Navbar
│ └── CartIcon (shows item count)
├── ProductList
│ └── ProductCard (has "Add to Cart" button)
└── CartSidebar (shows items, totals, remove buttons)
This structure is intentionally realistic. CartIcon and CartSidebar both need cart data. ProductCard needs to modify it. They share no direct parent except App.
We'll implement this cart four times, once with each approach, so the trade-offs are concrete and visible.
Stage 1: useState: Local State Done Right
useState is the foundation. Before reaching for anything else, understand what it does well.
It manages state that belongs to a single component, state that doesn't need to be shared, doesn't need to persist across navigation, and doesn't need to be accessed from a sibling or distant ancestor.
For our cart, let's start with just the ProductCard. It has a small piece of truly local state: whether the "Add to Cart" button has just been clicked and should show a brief "Added!" confirmation.
// src/components/ProductCard.tsx
import { useState } from 'react'
import { Product } from '@/types'
interface Props {
product: Product
onAddToCart: (product: Product) => void
}
export function ProductCard({ product, onAddToCart }: Props) {
// This state belongs entirely to this component.
// Nothing outside needs to know about it.
const [justAdded, setJustAdded] = useState(false)
function handleAdd() {
onAddToCart(product)
setJustAdded(true)
setTimeout(() => setJustAdded(false), 1500)
}
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>€{product.price.toFixed(2)}</p>
<button onClick={handleAdd} disabled={justAdded}>
{justAdded ? 'Added!' : 'Add to Cart'}
</button>
</div>
)
}
justAdded is the textbook case for useState: it's ephemeral, it's local, and nothing outside ProductCard would ever need to know about it.
Now, where does the actual cart state live?
If we lift it to App and pass it down as props, we get this:
// src/App.tsx
import { useState } from 'react'
import { CartItem, Product } from '@/types'
export function App() {
const [cartItems, setCartItems] = useState<CartItem[]>([])
function addToCart(product: Product) {
setCartItems(prev => {
const existing = prev.find(item => item.product.id === product.id)
if (existing) {
return prev.map(item =>
item.product.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)
}
return [...prev, { product, quantity: 1 }]
})
}
function removeFromCart(productId: string) {
setCartItems(prev => prev.filter(item => item.product.id !== productId))
}
return (
<div>
<Navbar cartItems={cartItems} />
<ProductList cartItems={cartItems} onAddToCart={addToCart} />
<CartSidebar
cartItems={cartItems}
onRemove={removeFromCart}
/>
</div>
)
}
This works. For a small app, this is perfectly fine.
The question is: when does it stop being fine?
Stage 2: Prop Drilling and When It Actually Becomes a Problem
Right now, cartItems and the cart callbacks are passed from App to three direct children. One level deep. No real problem.
But Navbar doesn't use cartItems directly, it passes them down to CartIcon:
// src/components/Navbar.tsx
interface Props {
cartItems: CartItem[] // Navbar doesn't need this...
}
export function Navbar({ cartItems }: Props) {
return (
<nav>
<Logo />
<CartIcon cartItems={cartItems} /> {/* ...it just passes it here */}
</nav>
)
}
And ProductList doesn't use onAddToCart directly, it passes it to each ProductCard:
// src/components/ProductList.tsx
interface Props {
cartItems: CartItem[] // Also just passing through
onAddToCart: (product: Product) => void
}
export function ProductList({ cartItems, onAddToCart }: Props) {
return (
<div>
{products.map(product => (
<ProductCard
key={product.id}
product={product}
onAddToCart={onAddToCart}
/>
))}
</div>
)
}
This is prop drilling: components accepting props purely to pass them to their children, with no use for those props themselves.
At two levels, it's annoying. At four or five levels, it becomes a maintenance problem:
- Every intermediate component needs to be updated when the shape of the data changes.
- It's hard to understand which component actually uses the data vs which one is just a courier.
- Refactoring the tree requires touching every level simultaneously.
This is the signal to reach for something else.
But the question is: what something else?
Stage 3: Context API - What It's Good For and Where It Breaks
Context is React's built-in solution to prop drilling. It lets you put data in a "context" at a high level in the tree, and read it directly from any descendant, without threading it through every intermediate component.
Let's implement the cart with Context:
// src/context/CartContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react'
import { CartItem, Product } from '@/types'
interface CartContextValue {
cartItems: CartItem[]
addToCart: (product: Product) => void
removeFromCart: (productId: string) => void
totalItems: number
}
const CartContext = createContext<CartContextValue | null>(null)
export function CartProvider({ children }: { children: ReactNode }) {
const [cartItems, setCartItems] = useState<CartItem[]>([])
function addToCart(product: Product) {
setCartItems(prev => {
const existing = prev.find(item => item.product.id === product.id)
if (existing) {
return prev.map(item =>
item.product.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)
}
return [...prev, { product, quantity: 1 }]
})
}
function removeFromCart(productId: string) {
setCartItems(prev => prev.filter(item => item.product.id !== productId))
}
const totalItems = cartItems.reduce((sum, item) => sum + item.quantity, 0)
return (
<CartContext.Provider value={{ cartItems, addToCart, removeFromCart, totalItems }}>
{children}
</CartContext.Provider>
)
}
export function useCart() {
const context = useContext(CartContext)
if (!context) throw new Error('useCart must be used within CartProvider')
return context
}
Now Navbar, CartIcon, ProductCard, and CartSidebar can all access the cart directly:
// src/components/CartIcon.tsx
import { useCart } from '@/context/CartContext'
export function CartIcon() {
const { totalItems } = useCart() // Direct access, no prop drilling
return <div className="cart-icon">🛒 {totalItems}</div>
}
// src/components/ProductCard.tsx
import { useCart } from '@/context/CartContext'
export function ProductCard({ product }: { product: Product }) {
const { addToCart } = useCart()
const [justAdded, setJustAdded] = useState(false)
function handleAdd() {
addToCart(product)
setJustAdded(true)
setTimeout(() => setJustAdded(false), 1500)
}
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>€{product.price.toFixed(2)}</p>
<button onClick={handleAdd} disabled={justAdded}>
{justAdded ? 'Added!' : 'Add to Cart'}
</button>
</div>
)
}
App is now clean:
// src/App.tsx
export function App() {
return (
<CartProvider>
<Navbar />
<ProductList />
<CartSidebar />
</CartProvider>
)
}
This is much better. No prop drilling. Clean component interfaces.
So why not stop here?
The re-render problem
Context has a performance characteristic that surprises many developers: every component that consumes a context re-renders whenever any value in that context changes.
Let's say your context holds both cartItems and userPreferences:
// ❌ One context for unrelated data, a performance trap
const AppContext = createContext({
cartItems: [],
addToCart: () => {},
userPreferences: { theme: 'dark', language: 'en' },
updatePreferences: () => {},
})
Now, when a user adds a product to the cart, every component consuming AppContext re-renders, including those that only care about userPreferences and have nothing to do with the cart.
In a small app, this is invisible. As the app grows and the context accumulates more state, it becomes a real performance problem that's notoriously hard to debug, because the cause (a context update) and the symptom (a slow UI) are spatially distant in the code.
The fix is to split contexts by concern:
// ✅ Separate contexts for separate concerns
const CartContext = createContext(/* cart state */)
const UserPreferencesContext = createContext(/* user preferences */)
But this adds overhead, multiple providers, multiple hooks, multiple files. And it still doesn't solve the fundamental issue: any component that needs any value from CartContext will re-render on every cart update, even if it only cares about totalItems and cartItems didn't change in a way that affects totalItems.
When Context is the right tool
Context is excellent for:
- Static or rarely-changing data: theme, locale, authenticated user info, feature flags.
- Avoiding prop drilling for configuration: things components need to read but almost never cause re-renders because they don't change.
- Dependency injection: passing services or callbacks down the tree without threading them through props.
Context is the wrong tool for:
- Frequently updated shared state: a cart that changes on every user interaction.
- State that many components subscribe to independently: each subscriber re-renders on every update.
- Complex state with many actions: the logic in the Provider grows unwieldy quickly.
If you find yourself optimizing Context with useMemo and useCallback to prevent re-renders, that's a signal you've pushed Context beyond what it was designed for.
Stage 4: Zustand, Shared State Without the Ceremony
Zustand is a small, fast state management library built specifically for the problems Context struggles with. It uses a store outside the React tree, which means components can subscribe to specific slices of state, and only re-render when those slices change.
Let's rewrite the cart as a Zustand store:
// src/store/cartStore.ts
import { create } from 'zustand'
import { CartItem, Product } from '@/types'
interface CartState {
cartItems: CartItem[]
addToCart: (product: Product) => void
removeFromCart: (productId: string) => void
clearCart: () => void
totalItems: () => number
totalPrice: () => number
}
export const useCartStore = create<CartState>((set, get) => ({
cartItems: [],
addToCart: (product) => {
set(state => {
const existing = state.cartItems.find(
item => item.product.id === product.id
)
if (existing) {
return {
cartItems: state.cartItems.map(item =>
item.product.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
),
}
}
return {
cartItems: [...state.cartItems, { product, quantity: 1 }],
}
})
},
removeFromCart: (productId) => {
set(state => ({
cartItems: state.cartItems.filter(
item => item.product.id !== productId
),
}))
},
clearCart: () => set({ cartItems: [] }),
// Derived values as functions, computed on access, not stored
totalItems: () =>
get().cartItems.reduce((sum, item) => sum + item.quantity, 0),
totalPrice: () =>
get().cartItems.reduce(
(sum, item) => sum + item.product.price * item.quantity,
0
),
}))
Now each component subscribes to exactly what it needs:
// src/components/CartIcon.tsx
// Only re-renders when totalItems changes
import { useCartStore } from '@/store/cartStore'
export function CartIcon() {
const totalItems = useCartStore(state => state.totalItems())
return <div className="cart-icon">🛒 {totalItems}</div>
}
// src/components/ProductCard.tsx
// Only re-renders when addToCart reference changes (it doesn't)
import { useCartStore } from '@/store/cartStore'
export function ProductCard({ product }: { product: Product }) {
const addToCart = useCartStore(state => state.addToCart)
const [justAdded, setJustAdded] = useState(false)
function handleAdd() {
addToCart(product)
setJustAdded(true)
setTimeout(() => setJustAdded(false), 1500)
}
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>€{product.price.toFixed(2)}</p>
<button onClick={handleAdd} disabled={justAdded}>
{justAdded ? 'Added!' : 'Add to Cart'}
</button>
</div>
)
}
// src/components/CartSidebar.tsx
// Re-renders when cartItems changes, which is exactly when it should
import { useCartStore } from '@/store/cartStore'
export function CartSidebar() {
const cartItems = useCartStore(state => state.cartItems)
const removeFromCart = useCartStore(state => state.removeFromCart)
const totalPrice = useCartStore(state => state.totalPrice())
return (
<aside className="cart-sidebar">
{cartItems.map(item => (
<div key={item.product.id}>
<span>{item.product.name} × {item.quantity}</span>
<button onClick={() => removeFromCart(item.product.id)}>
Remove
</button>
</div>
))}
<p>Total: €{totalPrice.toFixed(2)}</p>
</aside>
)
}
No Provider. No wrapping. No prop drilling. Each component subscribes to exactly the slice it needs.
CartIcon does not re-render when cartItems changes, it only re-renders when totalItems() returns a different value. That's the performance win Context can't give you without significant extra work.
What Zustand adds beyond Context
- Selective subscriptions: components only re-render for the state they care about.
- No provider required: the store lives outside the component tree, accessible anywhere, including outside React (utility functions, event handlers, tests).
- Actions co-located with state: logic lives in the store, not scattered across components or context files.
-
Middleware support: you can add
persist(localStorage sync),devtools(Redux DevTools integration), orimmer(immutable update syntax) with one line each.
// Adding persistence in one line
import { persist } from 'zustand/middleware'
export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
// ...same store definition
}),
{ name: 'cart-storage' } // key in localStorage
)
)
The cart now survives page refreshes. With Context, you'd need to wire this manually.
Where Redux Fits (and Where It Doesn't)
Redux is the elephant in the room of React state management. For years, it was the default answer. It's worth being precise about when it's still the right one.
Redux earns its complexity when you have:
A large team with strict data flow requirements. Redux's unidirectional data flow and explicit action types make state changes auditable and predictable. In a team of twenty developers, that discipline has real value.
Complex state interactions with time-travel debugging needs. The Redux DevTools' ability to replay actions and inspect state at every step is genuinely powerful for debugging complex flows.
An existing Redux codebase. Migrating away from Redux has a cost. If the codebase is well-structured and the team knows it, the cost of switching often outweighs the benefits.
Where Redux is usually the wrong choice:
- New projects without an existing Redux investment.
- Small to medium teams where the boilerplate overhead slows everyone down.
- Apps where Zustand or Context would solve the actual problem with less ceremony.
Redux Toolkit has significantly reduced the boilerplate of modern Redux, if you're using Redux, you should be using Redux Toolkit. But even with Toolkit, Zustand remains simpler for the majority of use cases that don't require Redux's specific strengths.
The Decision Tree
Here's the framework. When you're deciding where new state should live, walk through these questions in order:
Is this state used by only one component?
│
├── YES → useState
│ (local, ephemeral, no sharing needed)
│
└── NO → Is it shared, but changes infrequently
(theme, locale, auth user, feature flags)?
│
├── YES → Context API
│ (low update frequency = no re-render problem)
│
└── NO → Does it change frequently, or do
many components subscribe independently?
│
├── YES → Zustand
│ (selective subscriptions, no re-render issues)
│
└── Is your team large (10+ devs), or do you need
strict auditability / time-travel debugging?
│
├── YES → Redux Toolkit
│
└── NO → Still Zustand
In practice, most apps end up using a combination:
-
useStatefor local UI state (open/closed, form inputs, loading indicators). - Context for static configuration (theme, i18n, auth).
- Zustand for shared, dynamic state (cart, notifications, user session after login).
These three layers don't compete, they complement each other.
Quick reference
| Tool | Best for | Watch out for |
|---|---|---|
useState |
Local, component-scoped state | Lifting too high - that's the signal to move on |
| Context API | Infrequently changing shared data | Re-renders when used for dynamic state |
| Zustand | Frequently updated shared state | Overkill for truly local state |
| Redux Toolkit | Large teams, complex flows, auditability | Boilerplate overhead in small/medium projects |
Final Thoughts
The most common state management mistake isn't choosing the wrong tool.
It's reaching for a powerful tool before you've felt the pain that tool was designed to solve.
useState in a single component isn't a sign of inexperience. It's the right tool for the job, until the job changes. The same goes for Context, Zustand, and Redux. Each one exists because a real problem existed first.
The developers who write the most maintainable React code aren't the ones who know the most tools. They're the ones who can accurately identify which problem they're solving and match it to the simplest tool that solves it.
Start with useState. Feel the prop drilling. Add Context. Feel the re-renders. Add Zustand. Stop when the pain stops.
That's not laziness. That's the right process.
Which tool is your team currently using for state management, and does it match the problem you're actually solving?
Drop your setup in the comments. I'm especially curious about teams that migrated away from Redux: what pushed you over the edge?
If this was useful, a ❤️ or a 🦄 helps it reach more developers making this decision.
And follow along for the next article in the series.
Top comments (0)