Your app has a cart. The header shows the item count, the product page adds items, and the checkout page lists them. Three separate parts of the tree need the same data. Where should it live?
That question — where does state live? — is most of what “state management” means. The tools matter less than the taxonomy, so this lesson starts there, then walks through Context, Zustand, and Redux Toolkit, and finally the boundary you must respect: server data is not the same as client state.
Three kinds of state
| Kind | Examples | Where it belongs |
|---|---|---|
| Local UI state | Open menu, form input, selected tab | useState inside the component that owns it |
| Shared client state | Cart, theme, logged-in user, draft filters | Lifted state, Context, Zustand, or Redux |
| Server state | Posts, products, orders, profile data | A data-fetching layer (TanStack Query) with caching |
Most “state management problems” are one of two mistakes: putting local state in a global store, or copying server data into a store and letting the two drift apart. Get the classification right and the tool choice becomes obvious.
Start local, then lift
State should live as close as possible to the components that use it. When two siblings need the same value, lift it to their closest common parent:
function TemperaturePanel() {
const [celsius, setCelsius] = useState(20)
return (
<div>
<TemperatureInput value={celsius} onChange={setCelsius} />
<TemperatureDisplay value={celsius} />
</div>
)
}The parent owns the value; both children receive it as props. No library needed. This is the correct answer far more often than people expect — a surprising number of apps install a state library before they have shared state at all.
Prop drilling — and when it actually hurts
Passing props through intermediate components that do not use them is called prop drilling:
App (owns user)
└── Layout ← passes user through, doesn't use it
└── Sidebar ← passes user through, doesn't use it
└── Avatar ← finally uses userTwo or three levels is fine — explicit props make data flow readable. Drilling becomes a problem when:
- The chain is deep and the intermediaries are purely mechanical.
- Many unrelated components need the same value (theme, locale, auth).
- Refactoring a prop name means touching every intermediate component.
That is the point where a shared mechanism earns its place.
Context: dependency injection for the tree
createContext + a provider makes a value available to every descendant, no matter how deep, without passing props:
import { createContext, useContext, useMemo, useState } from 'react'
const ThemeContext = createContext(null)
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light')
// Memoize so consumers don't re-render just because the provider re-rendered
const value = useMemo(() => ({ theme, setTheme }), [theme])
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) throw new Error('useTheme must be used inside ThemeProvider')
return context
}function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
{theme}
</button>
)
}The custom useTheme hook with the error message is the pattern to copy: it turns a footgun (using the context outside its provider) into a clear runtime error.
Context + useReducer for real state
Context alone holds a value; combined with a reducer it becomes a small state container:
// CartContext.jsx
import { createContext, useContext, useMemo, useReducer } from 'react'
const CartContext = createContext(null)
function cartReducer(state, action) {
switch (action.type) {
case 'added': {
const existing = state.find((item) => item.id === action.item.id)
if (existing) {
return state.map((item) =>
item.id === action.item.id ? { ...item, qty: item.qty + 1 } : item
)
}
return [...state, { ...action.item, qty: 1 }]
}
case 'removed':
return state.filter((item) => item.id !== action.id)
case 'cleared':
return []
default:
throw new Error(`Unknown action: ${action.type}`)
}
}
export function CartProvider({ children }) {
const [items, dispatch] = useReducer(cartReducer, [])
const value = useMemo(
() => ({
items,
total: items.reduce((sum, item) => sum + item.price * item.qty, 0),
addItem: (item) => dispatch({ type: 'added', item }),
removeItem: (id) => dispatch({ type: 'removed', id }),
clear: () => dispatch({ type: 'cleared' }),
}),
[items]
)
return <CartContext.Provider value={value}>{children}</CartContext.Provider>
}
export function useCart() {
const context = useContext(CartContext)
if (!context) throw new Error('useCart must be used inside CartProvider')
return context
}Zustand: minimal global state
Zustand is a small store where components subscribe to slices they care about, so a change to the cart's items does not re-render a component that only reads clear:
npm install zustand// store/cart.js
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useCart = create(
persist(
(set) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id)
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, qty: i.qty + 1 } : i
),
}
}
return { items: [...state.items, { ...item, qty: 1 }] }
}),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
clear: () => set({ items: [] }),
}),
{ name: 'cart' } // persists to localStorage automatically
)
)Consume it anywhere, selecting only what you need:
import { useCart } from './store/cart.js'
function CartBadge() {
const count = useCart((state) => state.items.reduce((sum, i) => sum + i.qty, 0))
return <span className="badge">{count}</span>
}
function AddToCartButton({ product }) {
const addItem = useCart((state) => state.addItem) // stable function reference
return <button onClick={() => addItem(product)}>Add to cart</button>
}
function CartTotal() {
const total = useCart((state) =>
state.items.reduce((sum, i) => sum + i.price * i.qty, 0)
)
return <p>Total: ${total.toFixed(2)}</p>
}Why teams like it: no providers, no boilerplate, selectors keep re-renders narrow, actions live next to the data they change, and persist handles storage. It scales comfortably to medium apps.
Selectors are the contract: useCart((state) => state.items) subscribes to one slice. Deriving values in the selector (like the total above) keeps derived data out of the store entirely — there is only one source of truth.
Redux Toolkit: conventions for large apps
Redux has the longest history and the strongest conventions. Redux Toolkit removes the old boilerplate: configureStore, createSlice, and Immer for safe “mutating” reducer code.
npm install @reduxjs/toolkit react-redux// store/cartSlice.js
import { createSlice } from '@reduxjs/toolkit'
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] },
reducers: {
itemAdded(state, action) {
const existing = state.items.find((i) => i.id === action.payload.id)
if (existing) existing.qty += 1 // Immer makes this safe
else state.items.push({ ...action.payload, qty: 1 })
},
itemRemoved(state, action) {
state.items = state.items.filter((i) => i.id !== action.payload)
},
},
})
export const { itemAdded, itemRemoved } = cartSlice.actions
export default cartSlice.reducer// store/index.js
import { configureStore } from '@reduxjs/toolkit'
import cartReducer from './cartSlice.js'
export const store = configureStore({
reducer: { cart: cartReducer },
})// main.jsx
import { Provider } from 'react-redux'
import { store } from './store/index.js'
createRoot(document.getElementById('root')).render(
<Provider store={store}>
<App />
</Provider>
)// anywhere in the tree
import { useDispatch, useSelector } from 'react-redux'
import { itemAdded } from './store/cartSlice.js'
function AddToCart({ product }) {
const dispatch = useDispatch()
return <button onClick={() => dispatch(itemAdded(product))}>Add</button>
}
function CartCount() {
const count = useSelector((state) => state.cart.items.reduce((s, i) => s + i.qty, 0))
return <span>{count}</span>
}Reach for Redux Toolkit when you need Redux DevTools time-travel debugging, middleware like logging and analytics, strict conventions across a large team, or RTK Query's generated API layer. For small single-purpose stores, Zustand does the same job with less ceremony.
Server state is not client state
The biggest state mistake in React apps: fetching posts into Redux or a context and treating them like owned data. Server data is a cache of someone else's data. It has staleness, background refetching, loading and error states, and mutations that invalidate other queries.
That is the job of a data-fetching library. TanStack Query is the standard choice:
import { useQuery } from '@tanstack/react-query'
function Posts() {
const { data: posts, isPending, isError } = useQuery({
queryKey: ['posts'],
queryFn: async () => {
const response = await fetch('/api/posts')
if (!response.ok) throw new Error('Failed to load posts')
return response.json()
},
staleTime: 30_000,
})
if (isPending) return <p>Loading…</p>
if (isError) return <p>Something went wrong.</p>
return <ul>{posts.map((post) => <li key={post.id}>{post.title}</li>)}</ul>
}Caching, retries, stale-while-revalidate, and refetch-on-focus come for free. The forms and data lesson covers queries and mutations in depth; the rule to remember now: server state belongs in a query cache, never copied into a global store.
The URL is state too
Filters, sorts, tabs, and pagination should often live in useSearchParams rather than any store:
const [searchParams, setSearchParams] = useSearchParams()
const genre = searchParams.get('genre') ?? 'all'The user can share the link, reload, and use the back button. That is state management with zero libraries.
Choosing the tool
| Situation | Tool |
|---|---|
| One component's UI state | useState / useReducer locally |
| Two or three siblings | Lift to the closest common parent |
| Theme, auth, locale for the whole tree | Context (split, memoized) |
| Cart, editor state, cross-cutting client state | Zustand (simple) or Redux Toolkit (conventions + devtools) |
| Shareable filters, tabs, pagination | URL via useSearchParams |
| Anything fetched from your API | TanStack Query — never a client store |
Try it
npm install zustand, split the two blocks into files, and click Add — the badge and total update from the same store.
import { create } from 'zustand'
const useCart = create((set) => ({
items: [],
add: (name, price) =>
set((state) => ({ items: [...state.items, { name, price }] })),
clear: () => set({ items: [] }),
}))
export default function App() {
const items = useCart((state) => state.items)
const add = useCart((state) => state.add)
const clear = useCart((state) => state.clear)
const total = items.reduce((sum, item) => sum + item.price, 0)
return (
<div style={{ padding: 24, fontFamily: 'system-ui' }}>
<h1>Cart ({items.length})</h1>
<button onClick={() => add('Keyboard', 80)}>Add keyboard</button>
<button onClick={() => add('Mouse', 40)}>Add mouse</button>
<button onClick={clear}>Clear</button>
<p>Total: ${total}</p>
</div>
)
}Check your understanding
What problem does Context actually solve?
Your app fetches a list of products. Where should that data live?
Cart with Context, then refactor to Zustand
Build a shopping cart twice to feel the trade-offs. Keep both versions in Git history.
- Context version. Create
CartProviderwithuseReducersupportingadded(increment qty if present),removed, andcleared. Exposeitems,total,addItem,removeItem, andclearthrough auseCarthook that throws outside the provider. - Build three consumers: a header badge with the item count, a product list with Add buttons, and a cart page listing items with quantities, remove buttons, and the total.
- Verify with React DevTools that adding an item re-renders the consumers and identify one component that re-renders unnecessarily. Fix the provider's memoization or split the context.
- Zustand version. Install
zustand, recreate the store withcreate, add thepersistmiddleware, and swap the consumers to selectors. Confirm the cart survives a reload. - URL state. Add a
?sort=price|namecontrol on the product list stored inuseSearchParams; reloading must preserve the sort. - Boundary check. Fetch products from
https://fakestoreapi.com/productswith a plainuseEffectfor now. Write one sentence in your README explaining why this data will move to TanStack Query in the next lesson instead of into the Zustand store. - Bonus: keep a
themein a separate small context and prove with DevTools that toggling the theme does not re-render the cart consumers.
Summary
- Classify state first: local UI, shared client, or server. Most problems come from misclassification.
- Keep state local as long as possible; lift it to the closest common parent; prop drilling matters only when chains get deep or wide.
- Context provides values to deep descendants — dependency injection, not a state manager; split contexts and memoize values.
- Context +
useReduceris a complete client-state pattern;useCart-style hooks with error checks keep usage safe. - Zustand gives selector-based global state with minimal code and built-in persistence.
- Redux Toolkit adds conventions, middleware, devtools, and RTK Query for large teams and apps.
- Server data belongs in a query cache like TanStack Query, never copied into a client store.
- URL state via
useSearchParamsmakes filters and pagination shareable for free.
Next: forms and data fetching — controlled inputs, React Hook Form with Zod, and TanStack Query for the full server-state story.