React State Management: A Practical Decision Guide

Choose the right React state tool for your app. Start with local state, add context for shared data, and reach for Redux Toolkit, Zustand, Jotai, or XState only when the pressure is real.

7 min read

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.

App.jsxApp.jsx
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.

App.jsxApp.jsx
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.

App.jsxApp.jsx
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.

LibraryModelBest fit
Redux ToolkitCentral store with slicesLarge apps with a clear action history
ZustandSmall hook based storesFast setup and less boilerplate
JotaiIndependent atomsFine grained derived values
XStateState machinesComplex, 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

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.
RunePowered by Rune AI

Frequently Asked Questions

When is useState enough for state management?

When the state lives in one component or is passed down one or two levels as props. useState with lifted state handles most small UI state.

Is Context a state management library?

No. Context is a way to pass values down the tree without prop drilling. It does not handle update logic, so pair it with useReducer for shared state.

Should server data live in a global store?

Usually not. Tools like TanStack Query manage fetching, caching, and stale server data better than a client state library can.

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.