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
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:
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:
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.
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.
| Aspect | useState / useReducer + lifting | A dedicated state library |
|---|---|---|
| Setup cost | None — built into React | A new dependency, a learning curve |
| Best for | Most component-local and moderately-shared state | Large amounts of state shared across many distant components |
| DevTools / time-travel debugging | Not built in | Often included (Redux DevTools, etc.) |
| When it stops being enough | Deeply nested prop-drilling, frequent Context re-renders | Rarely 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:
// 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
- 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.
Related reading
- React Server Components Explained: What They Are and When to Use Them — shares tags: react, javascript, web-development (same category).
- React useEffect Cleanup: A Practical Guide — shares tags: react, javascript, web-development.
- Next.js Middleware: What It's For and When to Avoid It — shares tags: react, web-development.
- Next.js Rendering Strategies: SSG, SSR, ISR, and Static Export Compared — shares tags: react, web-development.
- JavaScript Closures Explained: What They Actually Are and Why They Matter — shares tags: javascript.