ReactIntermediate

React State Management: useState, useReducer, and When You Actually Need a Library

Most React apps reach for a state management library long before they need one. Here's what useState and useReducer actually cover, and the honest signal for when it's time to add something more.

DevFieldGuideJuly 27, 2026 (updated July 29, 2026)6 min read
Share:

React's state management story gets more crowded every year, but the built-in tools cover more real cases than their reputation suggests — most apps never need to leave them.

useState: the default for local, independent state

jsx
function SearchBox() {
  const [query, setQuery] = useState("");
  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

useState is correct for state that's genuinely local to one component and doesn't need to be shared, and for state where each update is independent (not derived from complex logic involving the previous state and several possible actions).

useReducer: when state transitions get more complex

Once a component has several related pieces of state that update together, or update logic that depends on which action happened (not just a new value), useReducer reads more clearly than a pile of separate useState calls:

jsx
function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return { ...state, count: state.count + 1 };
    case "reset":
      return { count: 0, step: state.step };
    case "setStep":
      return { ...state, step: action.step };
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}
 
function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });
  return (
    <button onClick={() => dispatch({ type: "increment" })}>
      {state.count}
    </button>
  );
}

The reducer function is a pure function of (state, action) => newState — every possible transition is named and centralized in one place, rather than scattered across several setX calls that each need to independently reason about the current state.

Lifting state up: React's own answer to "sharing state"

Before reaching for any library, the built-in answer to "two components need the same state" is moving that state to their nearest shared parent and passing it down as props:

jsx
function Parent() {
  const [selected, setSelected] = useState(null);
  return (
    <>
      <ItemList selected={selected} onSelect={setSelected} />
      <ItemDetail selected={selected} />
    </>
  );
}

This is genuinely sufficient for a large share of "shared state" problems — reaching for Context or a library before actually trying this is a common premature step.

Context: for state that's read widely, not written often

useContext solves prop-drilling for values read by many distant components (theme, current user, locale) but it's not a general state management solution — every consumer of a context re-renders when that context's value changes, which becomes a real performance problem for state that updates frequently.

jsx
const ThemeContext = createContext("light");
 
function App() {
  const [theme, setTheme] = useState("light");
  return (
    <ThemeContext.Provider value={theme}>
      <Page />
    </ThemeContext.Provider>
  );
}

Good fit: theme, authenticated user, locale — things that change rarely. Poor fit: form input state, a frequently-updating counter, anything with many rapid updates read by many components.

AspectuseState / useReducer + liftingA dedicated state library
Setup costNone — built into ReactA new dependency, a learning curve
Best forMost component-local and moderately-shared stateLarge amounts of state shared across many distant components
DevTools / time-travel debuggingNot built inOften included (Redux DevTools, etc.)
When it stops being enoughDeeply nested prop-drilling, frequent Context re-rendersRarely a limit for well-architected apps

The honest signal for reaching beyond React's built-ins

Not "the app has grown," but a specific, felt pain: prop-drilling through five or six component layers just to pass a setter down, or a Context value updating so frequently that unrelated components visibly re-render and cause real performance problems. Those are the actual signals — "this feels like it should use Redux" without one of those concrete symptoms is usually premature.

Derived state: the state you shouldn't store at all

A common source of unnecessary complexity is storing a value in state that could instead be computed directly from existing state or props on every render:

jsx
// Unnecessary state — filteredItems can get out of sync with items/query
const [items, setItems] = useState([]);
const [query, setQuery] = useState("");
const [filteredItems, setFilteredItems] = useState([]);
 
useEffect(() => {
  setFilteredItems(items.filter((i) => i.name.includes(query)));
}, [items, query]);
 
// Just compute it during render — always correct, no sync bug possible
const filteredItems = items.filter((i) => i.name.includes(query));

The second version can never drift out of sync with items or query, because it's recomputed fresh on every render rather than stored and manually kept updated — a real category of bug (stale derived state) that simply can't happen if the value is never stored as state in the first place.

Colocating state as close as possible to where it's used

A related principle: state should live in the component that actually needs it, moved up to a shared parent only when a sibling genuinely needs access too. Lifting everything to the top of the app "to be safe" causes unrelated components to re-render on every unrelated state change — keeping state as local as its actual usage requires is a simple, high-leverage habit that prevents a lot of unnecessary re-rendering before it starts.

Common mistakes

Common mistakes
  • Reaching for a state management library on day one of a new project, before any real prop-drilling or performance pain has actually shown up. Most small-to-medium apps are well served by useState, useReducer, and lifting state for a long time.
  • Putting server data (API responses) into useState and hand-rolling loading/error/refetch logic, when a dedicated data-fetching library handles caching and staleness far more correctly with less code.
  • Using one giant Context for all app state instead of splitting by what changes together — this means every consumer re-renders on any change anywhere, even to state it doesn't use.
  • Reaching for useReducer for state that's genuinely simple and independent. A reducer with one action type and no real transition logic is often just a more verbose useState in disguise.

Frequently Asked Questions

DevFieldGuide
DevFieldGuide

Editorial Team

Practical tutorials and developer tools, written and maintained by the DevFieldGuide team.

Enjoyed this article?

Get the next one straight to your inbox, along with the best of what we publish each week.

Related Articles

More in React

View all