Every React project starts with useState. It handles form inputs, toggles, local UI flags. For a surprisingly long time, it's all you need.
The trouble starts when two components need the same piece of state and neither is a parent of the other. You hoist the state up. Then a third component needs it, so you hoist it further. Before long, you're passing props through four layers of components that don't use them, just to get data to a leaf node.
This is the point where most teams reach for a state management library. But the decision is more nuanced than "useState is bad, use Redux." The right choice depends on the shape of your state, how often it changes, and how many components need to read it.
The prop drilling threshold
Prop drilling isn't inherently bad. If you're passing a prop through two levels, that's fine. The component tree is shallow enough that you can trace the data flow by reading the code.
Three levels is a judgement call. Four or more is almost always a sign that the state should live somewhere else.
The key question is: are the intermediate components using the prop, or just forwarding it? If they're using it, the data flow makes sense. If they're just passing it through, you're coupling those components to a data dependency they don't care about.
// This is fine — the intermediate component uses the prop
function Dashboard({ user }: { user: User }) {
return (
<div>
<Greeting name={user.name} />
<UserStats userId={user.id} />
</div>
)
}
// This isn't — Layout has no business knowing about theme
function Layout({ theme, children }: { theme: Theme; children: ReactNode }) {
return (
<div>
<Sidebar theme={theme} />
<main>{children}</main>
</div>
)
}
Context: the first step up
React Context is the obvious next step, and for many cases it's the right one. It eliminates prop drilling without adding a library. The mental model is straightforward: you put a value at the top, and any descendant can read it.
The problem with Context is re-renders. When the value in a Context changes, every component that consumes that Context re-renders. If you put your entire application state in a single Context, every state change re-renders every consumer.
The fix is to split your contexts. Put theme in one context, authentication in another, and feature flags in a third. Each consumer only re-renders when the specific context it reads from changes.
// Split contexts by update frequency
const ThemeContext = createContext<Theme>(defaultTheme)
const AuthContext = createContext<AuthState>(defaultAuth)
// A component that only reads theme doesn't re-render
// when auth state changes
function ThemedButton() {
const theme = useContext(ThemeContext)
return <button style={{ background: theme.primary }}>Click</button>
}
This works well for state that changes infrequently: theme, locale, authentication status, feature flags. For state that changes often, like form data or real-time updates, Context causes too many re-renders.
When you actually need a library
You need a state management library when:
- Multiple unrelated components read and write the same state frequently
- The state has complex update logic (reducers, derived state, async transitions)
- You need to optimise which components re-render on a state change
For most applications I've worked on, Zustand has been the right answer. It's small, the API is minimal, and it supports selective subscriptions out of the box.
import { create } from 'zustand'
interface CartStore {
items: CartItem[]
addItem: (item: CartItem) => void
removeItem: (id: string) => void
total: () => number
}
const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
removeItem: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}))
// Only re-renders when items.length changes
function CartBadge() {
const count = useCartStore((s) => s.items.length)
return <span>{count}</span>
}
The selector pattern (useCartStore((s) => s.items.length)) is the key. The component only re-renders when the selected value changes, not when any part of the store changes.
Redux: when and why
Redux still has its place, but that place is narrower than the ecosystem suggests. It's the right choice when:
- The state has complex, interdependent update logic
- You need middleware for side effects (sagas, thunks)
- The team is large and you need enforced patterns
- You need time-travel debugging or state serialisation
For most projects under ten developers, Redux is more structure than you need. The boilerplate has improved significantly with Redux Toolkit, but the conceptual overhead of actions, reducers, selectors, and middleware is still there.
If you're starting a new project and you aren't sure whether you need Redux, you don't need Redux. Start with useState and Context. Move to Zustand when Context re-renders become a problem. Move to Redux only if Zustand's model doesn't fit your state shape.
Server state is not application state
This is the most common mistake I see in React codebases: treating server data as application state. If you're fetching a list of users from an API and putting it in Redux, you're solving the wrong problem.
Server state has different semantics. It's owned by the server. It can become stale. Multiple clients might modify it concurrently. It needs caching, background refetching, and optimistic updates.
React Query (or SWR) handles all of this. The mental shift is: server data is a cache, not state. You declare what data a component needs, and the library handles fetching, caching, invalidation, and deduplication.
function UserList() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 5 * 60 * 1000,
})
if (isLoading) return <Spinner />
if (error) return <Error message={error.message} />
return <ul>{data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
}
Once you move server state to React Query, most applications have very little application state left. Usually just UI state (modals, sidebars, form data) and maybe a small amount of shared client state (theme, auth).
My current approach
On every new project, I start with:
- useState for component-local UI state
- React Query for all server data
- Context for infrequent global state (theme, auth)
- Zustand if and when I need shared client state that changes frequently
I haven't needed Redux on a new project in over two years. That doesn't mean Redux is bad. It means the problem space it solves is smaller than most people think, and simpler tools handle the common cases better.
The section on server state vs application state is the thing I wish someone had told me two years ago. We had our entire API cache in Redux and it was a constant source of stale data bugs. Switched to React Query last month and deleted about 40% of our Redux code.
Solid breakdown. The prop drilling threshold framing is useful because it gives a concrete answer to the question juniors always ask: 'when do I stop passing props?' Two levels fine, four levels extract. Simple.