React State Management: Context, Props, or an External Store?
Start from the pain of prop drilling, see what Context actually fixes and what it leaves unsolved, and learn to recognize when it's time to reach for an external store.
Once a React app grows past a certain size, you'll run into the same question every time: where does this piece of data actually belong?
useState works fine at first, but once you have enough components and data needs to cross several layers, you start second-guessing yourself: keep passing props? Reach for Context? Or just bring in a store library?
This post lays out all three options, walks through what problem each one actually solves (and what new problems it creates), and ends with a practical way to decide between them.
Starting With the Simplest Option: Props
Props are React's original way of passing data: a parent hands data down to a child. One direction, explicit, easy to trace.
function App() {
const [user, setUser] = useState({ name: 'John' })
return <Profile user={user} />
}
function Profile({ user }) {
return <h1>{user.name}</h1>
}This works great when the component tree is shallow — the data flow is obvious, and it's arguably the pattern React champions most: one-way data flow.
The trouble starts once the tree gets deeper. Say App needs to hand user down to Avatar, four levels down, but Layout, Sidebar, and Widget sit in between and none of them actually need user. You still have to thread it through every one of them.
function App() {
const [user, setUser] = useState({ name: 'John' })
return <Layout user={user} />
}
function Layout({ user }) {
return <Sidebar user={user} />
}
function Sidebar({ user }) {
return <Widget user={user} />
}
function Widget({ user }) {
return <Avatar user={user} />
}This is what everyone calls prop drilling. Layout, Sidebar, and Widget couldn't care less who user is, but they're forced to catch it and pass it along anyway. As the tree grows, all this pass-through props makes the code hard to maintain — change the shape of user and you might have to update the types and params of five or six components in between.
Once you feel that pain, the obvious question is: can a deeply nested component just grab the data directly, without bothering everything in between?
Context: A Portal Past the Middle Layers
The Context API exists specifically to solve prop drilling. Think of it as opening a portal inside the component tree: a component higher up drops data in, and anything further down can just plug in and grab it, no matter how many layers sit in between.
const UserContext = createContext(null)
function App() {
const [user, setUser] = useState({ name: 'John' })
return (
<UserContext.Provider value={user}>
<Layout />
</UserContext.Provider>
)
}
function Avatar() {
const user = useContext(UserContext)
return <span>{user.name}</span>
}Layout, Sidebar, and Widget never even need to know user exists. Avatar just calls useContext(UserContext) and gets the data directly. Much cleaner, right?
But Context isn't a silver bullet. There are at least two real costs worth taking seriously.
Cost One: Every Subscribed Component Re-renders as a Whole
When a Context updates, every component subscribed to it re-renders — even if it only uses a tiny slice of value. Say you bundle user and theme into the same Context: change just theme, and every component that only cares about user re-renders too.
const AppContext = createContext(null)
function App() {
const [user, setUser] = useState({ name: 'John' })
const [theme, setTheme] = useState('dark')
// theme changed, so the whole value object is a new reference
// every component using AppContext re-renders
const value = { user, setUser, theme, setTheme }
return (
<AppContext.Provider value={value}>
<Layout />
</AppContext.Provider>
)
}For data that rarely changes — the logged-in user, locale settings — this usually isn't a big deal. But stuff Context with something that updates constantly, like input values or mouse coordinates, and you can end up paying for a lot of unnecessary re-renders.
Cost Two: Context Is a Delivery Mechanism, Not a State Management Tool
This part gets overlooked a lot. Context solves how to get data down to the bottom of the tree — not how to organize, update, or split up complex state. If your state logic is already complicated (several pieces of state depending on each other, async updates, needing access from outside the component tree), leaning on useContext plus useState or useReducer alone tends to produce logic that's increasingly hard to maintain.
Between these two costs, at some point you start wondering: is it time to switch to something actually designed for state management?
When Should You Reach for an External Store?
External stores — libraries like Zustand, Redux, Jotai, Recoil — solve the two things Context doesn't handle well: precise subscriptions and how state logic is organized.
Take Zustand as an example:
import { create } from 'zustand'
const useUserStore = create((set) => ({
user: { name: 'John' },
setUser: (user) => set({ user }),
}))
function Avatar() {
// only subscribes to user — other fields changing won't re-render this component
const user = useUserStore((state) => state.user)
return <span>{user.name}</span>
}The key line is useUserStore((state) => state.user): the component only subscribes to the exact slice of data it actually uses, so changes to any other field in the store don't touch it. That solves Context's "everything re-renders" problem, and it also lets you keep state logic — update rules, async operations, derived data — centralized in the store instead of scattered across useEffect calls in a dozen components.
So when should you actually reach for a store instead of pushing Context further? A few signals are worth watching for:
- State is read by lots of components scattered across the tree, and it updates often. Context's re-render cost only gets more noticeable as this grows.
- The state logic itself is complex — multiple fields affecting each other, async flows, or state that needs to be accessed outside components (utility functions, event listeners).
- State needs to survive across routes or pages, not just be shared within one subtree.
- The team wants proper debugging tools. Most store libraries ship with DevTools that show you where and when every state change happened — something plain Context makes hard to get.
On the flip side, if all you need is to pass low-frequency global settings — user info, locale, theme color — down to the bottom of the tree, Context is usually more than enough.
Don't Forget: Server State Is a Different Beast
Before making this call, there's a distinction worth separating out, because it often gets lumped in with everything else: client state and server state are fundamentally different, and they shouldn't be handled with the same logic.
Client state lives only on the frontend and comes from user interaction — form input, whether a modal is open, which tab is currently selected. This is exactly what Context or a store should be responsible for.
Server state, on the other hand, is data fetched from a backend API, and it comes with its own set of problems: caching, background revalidation, multiple components requesting the same thing at once, loading and error states to handle. Cramming all of that into Context or a hand-rolled store almost always means reinventing the wheel — rebuilding a worse version of what React Query or SWR already do.
So before asking "Context or a store," ask the question one level up first: is this client state or server state? If it's the latter, the answer usually isn't Context or a homegrown store at all — it's a dedicated data-fetching library.
A Simple Decision Flow
Putting all of this into a sequence, here's how to work through "where should this state live" step by step:
- Does this state only matter to one or two levels of components? If so,
useStateplus props is all you need — no Context, no store. - Is it data from an API? If so, hand it to something like React Query or SWR to deal with caching and revalidation — don't stuff it into Context or a store.
- Is it a global setting used all over the tree but rarely updated — user info, locale, theme? This is exactly what Context is good at.
- Is it global state with complex logic, frequent updates, or a need to be accessed outside the component tree? This is when a store library like Zustand or Redux earns its keep, trading a bit of setup for precise subscriptions and cleaner state organization.
Wrapping Up
Props, Context, and a store aren't mutually exclusive options you pick one of — they're tools that solve problems at different layers:
- Props are for simple parent-to-child handoffs, with a clear data flow — the default starting point.
- Context solves prop drilling and fits low-frequency global settings scattered across the tree — just watch out for it re-rendering every subscriber as a whole.
- A store earns its place when state logic is complex, updates are frequent, or you need precise subscriptions — at the cost of an extra dependency and some learning curve.
- Server state shouldn't be run through any of the above logic in the first place — a dedicated data-fetching tool is the right answer from the start.