State Management in React: Start with the Tree
State management is often described as the hardest part of building applications. That challenge has spawned countless libraries over the years, each promising to simplify the problem. Yet a significant part of that difficulty comes from over-engineering solutions before the problem is fully understood.
The key is to think about how your application's state maps to its component tree. React components aren't just building blocks for the UI — they should also be the natural boundary for state.
Why Redux Often Went Too Far
Redux's rise to prominence wasn't primarily due to its architecture; it succeeded because react-redux solved the prop drilling problem. The ability to share data across distant parts of the tree by wrapping a component in a connect function was genuinely transformative.
However, this ease of access led developers down a problematic path: putting all state, not just genuinely global state, into the Redux store. Local concerns like whether a modal is open or a form's input values end up entangled in reducers, action creators, and dispatch calls. This creates a web of indirection that makes you open many files just to trace a simple interaction. It also doesn't scale gracefully — as the app grows, this problem compounds, and mocking state for testing becomes unwieldy.
Even without Redux, centralizing all state also introduces performance hazards. When a Context.Provider receives a new value, every consumer re-renders, even if it only needs a small slice of the data. Keeping state close to where it’s consumed neatly sidesteps these issues.
You Already Have a State Management Library
Here's the underappreciated truth: your React installation already includes a capable state management solution. No extra npm install needed, no extra bytes shipped to users, no third-party APIs to learn.
React itself is a state management library. You assemble it into a tree of components, managing individual elements of state inside the appropriate components. Trusting each component to manage its own local, low-level state remains one of the most effective patterns in React.
function Counter() {
const [count, setCount] = React.useState(0)
const increment = () => setCount((c) => c + 1)
return <button onClick={increment}>{count}</button>
}
function App() {
return <Counter />
}
All of this also works with class components. The newer hooks merely streamline the process, especially when we get to context.
class Counter extends React.Component {
state = { count: 0 }
increment = () => this.setState(({ count }) => ({ count: count + 1 }))
render() {
return <button onClick={this.increment}>{this.state.count}</button>
}
}
Lifting State Up Before Adding Libraries
When state needs to be shared between components, the built-in solution shouldn't be a new dependency. It’s "Lifting State Up," a pattern as old as React itself. Instead of worrying about getting a count value from one component into another, you move the responsibility for that state to their nearest common ancestor and pass data down via props.
function Counter({ count, onIncrementClick }) {
return <button onClick={onIncrementClick}>{count}</button>
}
function CountDisplay({ count }) {
return <div>The current counter count is {count}</div>
}
function App() {
const [count, setCount] = React.useState(0)
const increment = () => setCount((c) => c + 1)
return (
<div>
<CountDisplay count={count} />
<Counter count={count} onIncrementClick={increment} />
</div>
)
}
This approach works even when lifting state goes all the way to the top of your application hierarchy.
Composition First, Context Second
When prop drilling does become a nuisance, your first move should be to restructure components. You can often eliminate the extra layers of props entirely by leaning on component composition.
function App() {
const [someState, setSomeState] = React.useState('some state')
return (
<>
<Header someState={someState} onStateChange={setSomeState} />
<LeftNav someState={someState} onStateChange={setSomeState} />
<MainContent someState={someState} onStateChange={setSomeState} />
</>
)
}
function App() {
const [someState, setSomeState] = React.useState('some state')
return (
<>
<Header
logo={<Logo someState={someState} />}
settings={<Settings onStateChange={setSomeState} />}
/>
<LeftNav>
<SomeLink someState={someState} />
<SomeOtherLink someState={someState} />
<Etc someState={someState} />
</LeftNav>
<MainContent>
<SomeSensibleComponent someState={someState} />
<AndSoOn someState={someState} />
</MainContent>
</>
)
}
Composition does have limits. For cases where passing state through the tree is genuinely required, React's Context API — now officially supported — provides the next level of relief.
import * as React from 'react'
import { CountProvider, useCount } from './count-context'
function Counter() {
const [count, setCount] = useCount()
const increment = () => setCount((c) => c + 1)
return <button onClick={increment}>{count}</button>
}
function CountDisplay() {
const [count] = useCount()
return <div>The current counter count is {count}</div>
}
function CountPage() {
return (
<div>
<CountProvider>
<CountDisplay />
<Counter />
</CountProvider>
</div>
)
}
A reasonable next step is extracting the state and updater functions into a custom hook, elegantly packaging your application’s logic alongside the UI components that use it.
function useCount() {
const context = React.useContext(CountContext)
if (!context) {
throw new Error(`useCount must be used within a CountProvider`)
}
const [count, setCount] = context
const increment = () => setCount((c) => c + 1)
return {
count,
setCount,
increment,
}
}
Those implementing more complex transition logic can readily swap useState for useReducer within these same custom hooks, maintaining flexibility without sacrificing simplicity.
function countReducer(state, action) {
switch (action.type) {
case 'INCREMENT': {
return { count: state.count + 1 }
}
default: {
throw new Error(`Unsupported action type: ${action.type}`)
}
}
}
function CountProvider(props) {
const [state, dispatch] = React.useReducer(countReducer, { count: 0 })
const value = React.useMemo(() => [state, dispatch], [state])
return <CountContext.Provider value={value} {...props} />
}
function useCount() {
const context = React.useContext(CountContext)
if (!context) {
throw new Error(`useCount must be used within a CountProvider`)
}
const [state, dispatch] = context
const increment = () => dispatch({ type: 'INCREMENT' })
return {
state,
dispatch,
increment,
}
}
This pattern presents a few landmarks to keep in mind:
- Not everything belongs in a single context object. Your application can (and should) logically separate concerns, such as user settings from notifications, by using multiple providers.
- Not all contexts need to be globally accessible. Keep state as close to where it's needed as possible.
A page-focused hierarchy — where each section carries its own provider with data specific to its subtree — makes code splitting easy to integrate and isolates changes and debugging.
Server Cache vs UI State
All application state can be sorted into two broad buckets:
- Server Cache – information owned by your server that you’re duplicating locally for access speed (like user profiles).
- UI State – ephemeral details that only matter in the browser to control interactions (like whether a modal is open).
Mixing these two creates systemic design flaws. Managing a server cache has inherently different challenges—scalability, stale data, invalidation, retries—so it rightly requires an off-the-shelf solution. While you could hand-roll caching with your own useState and useContext, caching remains a notoriously difficult problem best left to specialists.
That’s why libraries like react-query deserve a place in a UI application. These aren't "state management" libraries—they’re caches. The handling of asynchronous data is the job they do best, separating this distinct concern from React's native, built-in state patterns.
Rendering Costs and State Churn
Following the guidance on state colocation usually keeps performance headaches at bay. When state-related slowness does appear, start by asking whether the components re-rendering after a given state change actually depend on that state. If they do, the bottleneck isn't your state management strategy—it's render speed, and fixing the slow render should be the priority.
If the re-rendering components produce no DOM updates or side effects, they're re-rendering unnecessarily. This is common and by itself rarely a real problem. Only when it genuinely becomes a bottleneck do you need to intervene, and there are a few practical routes:
- Break the state into separate logical slices rather than one centralized store, so a change to one part doesn't force updates across the whole app.
- Optimize how your context provider is structured.
- Consider a purpose-built library such as jotai.
There are indeed gaps where React's baked-in abstractions fall short, and among the external options jotai is the most promising for those cases. For a thorough description of the exact problem types these tools address, Dave McCabe's React Europe 2020 talk on Recoil lays it out well. Recoil and jotai solve similar problems and overlap significantly in approach, but jotai tends to be the preferred choice. Still, the vast majority of applications will never require an atomic state tool like either of them.
Final Thoughts
None of this philosophy requires hooks—it all works with class components too. Hooks make the approach neater, but the same strategy is completely viable in React 15. Keep state as localized as it can be, reach for context only when prop drilling becomes genuinely painful, and that discipline will pay off when you have to reason about state interactions later.



