React state management is the set of decisions you make about where state lives and how it updates as an app grows. The right answer starts with built in React state and adds a library only when that stops being enough.
Start with useState and lifted state
Most state belongs in one component. When two components need the same value, move it to their closest common parent and pass it down as props.
function App() {
const [count, setCount] = useState(0);
return (
<div>
<Counter value={count} />
<button onClick={() => setCount(count + 1)}>Add one</button>
</div>
);
}The parent owns count and the child only displays it. This pattern, called lifting state, is the cheapest way to share state. A step by step example is in lifting state up in React.
Lifting works until props have to travel through many layers that do not use them. That passing is called prop drilling, and it is the first real sign that a different tool would help.
Use useReducer for update logic
When a screen has many updates spread across handlers, move them into one reducer. A reducer keeps every transition in one function, so handlers only describe what happened.
function tasksReducer(tasks, action) {
switch (action.type) {
case "added":
return [...tasks, { id: action.id, text: action.text }];
case "deleted":
return tasks.filter((task) => task.id !== action.id);
default:
return tasks;
}
}The reducer returns a fresh array and never changes the old one. React compares state by identity, so a new array tells it the list changed. A reducer also makes each update easy to trace, because every change runs through one switch.
Share state with context
Context lets many components read a value without prop drilling. It is not a store by itself, so pair it with useReducer when the value must also update.
function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
return (
<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
{children}
</TasksDispatchContext.Provider>
</TasksContext.Provider>
);
}Any component inside the provider can read tasks or dispatch an action. The complete setup is in how to combine useReducer and Context.
Context re-renders every consumer when the value changes, so it fits values that change rarely. Frequent updates across a large tree are where a dedicated library starts to win.
When a library earns its place
A library helps when state is shared across many screens, when several teams touch the same data, or when you want middleware and time travel debugging. You feel the pressure when props travel through ten components or the same data is fetched in five places.
| Library | Model | Best fit |
|---|---|---|
| Redux Toolkit | Central store with slices | Large apps with a clear action history |
| Zustand | Small hook based stores | Fast setup and less boilerplate |
| Jotai | Independent atoms | Fine grained derived values |
| XState | State machines | Complex, branching workflows |
The four libraries solve different pressures. Redux Toolkit favors structure, Zustand favors speed, Jotai favors derived values, and XState favors explicit machine logic. The fastest way to see one in action is a small Zustand store, which needs no provider at all.
Keep server data out of client state
Server data has its own lifecycle: loading, caching, refetching, and error handling. A client state library is the wrong tool for that job.
Data libraries such as TanStack Query handle that lifecycle and keep the cache in sync. Mixing server cache into a global client store causes duplicate requests and stale UI.
Keeping them separate also keeps the client store small and easier to reason about. The boundary is explained in client state vs server state.
A decision checklist
- Start with useState, then lift state when two components share it.
- Move many updates into useReducer when handlers get crowded.
- Wrap shared values in context only when prop drilling hurts.
- Adopt Redux Toolkit for a large app that wants one predictable store.
- Adopt Zustand for small stores and less boilerplate.
- Adopt Jotai for fine grained derived state.
- Adopt XState for branching, event driven workflows.
Choose the smallest tool that solves the current problem, not the one that might solve a future one.
Rune AI
Key Insights
- Start with useState and lift state before adding a library.
- Use context with useReducer for shared state, not as a store.
- Add Redux Toolkit, Zustand, or Jotai only when state is shared across many screens.
- Keep server cache in a data library, not in client state.
Frequently Asked Questions
When is useState enough for state management?
Is Context a state management library?
Should server data live in a global store?
Conclusion
Pick the smallest tool that solves the real problem. Local state covers most components, context and a reducer cover shared UI state, and a library earns its place when several distant parts of the app must stay in sync.
More in this topic
How to Build a Dropdown Menu in React
Build a React dropdown menu with the ARIA menu button pattern. Handle open and close, keyboard arrows, and clicks outside the menu.
How to Animate React Components with Motion
Animate React components with the Motion library. Set up motion, add enter, hover, and exit animations, and respect reduced motion.
Headless UI Components Explained: Logic Without Locked Styling
Understand headless UI components and how libraries like Radix give you unstyled, accessible behavior that you style yourself.