React State Management — Stale Closure Data Loss
Users' form fields revert after each change due to stale closures.
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
- useState is a Hook for primitive local state — a single value, toggle, or simple string
- useReducer handles complex state with multiple sub-values or transitions that depend on previous state
- Both trigger re-renders only when state reference changes — mutations won't work
- useReducer is overkill for a single boolean but essential for multi-field forms or state machines
- Most production bugs come from stale closures caused by missing dependency arrays — not from choosing the wrong Hook
Imagine your React app is a restaurant. The menu on the wall, the orders on each table, the chef's queue — all of that is 'state': information that can change and needs to be remembered. When a customer updates their order, the waiter doesn't rewrite the whole menu — they only update that one table's slip. React state works exactly the same way: it's the app's living memory, and when a piece of that memory changes, only the components that care about it get updated. No jargon needed — it's just your app's way of remembering things.
Every real application has things that change: a shopping cart that fills up, a login button that becomes a logout button, a filter that narrows a product list. Without a reliable way to track and react to those changes, your UI would be a static photograph. React's state system is what turns that photograph into a live feed — it's the heartbeat of every dynamic UI you'll ever build.
The problem it solves is deceptively simple but brutally important: how does a component know when to re-render? React's answer is state. When state changes, React re-renders the affected component tree automatically. Without it, you'd be manually touching the DOM like it's 2009 jQuery again — and nobody wants that. But state also has layers: local state for a single component, shared state for siblings, global state for the whole app. Picking the wrong tool for the wrong layer is the source of most state-related bugs.
By the end of this article you'll know the difference between useState and useReducer (and exactly when to reach for each), how to share state across components without prop-drilling yourself into madness, and the three most common state mistakes that silently break apps. Whether you're building a todo list or a production SaaS dashboard, this is the mental model you'll use every single day.
What is React State Management?
State management is how React remembers things across renders. Think of it as your component's short-term memory. Without it, every render starts from scratch — no input values, no toggle states, no data from an API call. The core idea is simple: each piece of state has a current value and a way to update it, and when you update, React re-renders only the parts of the tree that depend on that state.
Here's what most tutorials skip: React doesn't watch your variables. It doesn't detect changes to objects or arrays. It relies on you telling it explicitly via setState or dispatch. If you mutate a state object directly, React has no idea anything changed. The UI stays frozen while your data silently corrupts. That's the single biggest source of state bugs in production.
State also has granularity. A toggle button needs a boolean. A form with ten fields needs an object or a reducer. And when two components need the same data, you lift state up or use Context. Start with the simplest thing that works — you can always refactor later. Just don't start with Redux for a three-field form.
useState in Depth
useState is your bread-and-butter Hook for local state. It returns a tuple: the current value and a setter function. Call the setter with a new value, React schedules a re-render. That's it. But there's nuance.
First, the setter replaces the value — it doesn't merge like this.setState in class components. If you have an object, you need to spread the previous state manually every time. That's why useState gets messy for complex shapes.
Second, if you call setState with the same value as current (using Object.is comparison), React may skip the re-render for primitives. But for objects, even if contents are identical, if the reference is new, React re-renders. That's wasteful if you recompute an object every render without changes.
Third, the functional update form is non-negotiable when the new state depends on the old. Without it, batching bites you hard: multiple setState calls in the same event handler all see the same stale value. Always use setCount(prev => prev + 1) over setCount(count + 1) if you're incrementing.
One more trap: never call useState inside conditions, loops, or nested functions. React relies on the order of Hook calls being identical across renders. Break that rule and state silently shifts — your first useState might start returning the second one's value. The React docs warn about it, but teams still ship this bug.
- React uses reference equality to decide if the box contents changed. If you put the same object back, React may skip re-rendering.
- Mutating the object inside the box doesn't change the box itself — you must replace the whole object with a new one.
- Functional updates give you the previous value, so you can compute the next without worrying about stale closures.
useReducer in Depth
useReducer is for when state logic gets too complex for a handful of useState calls. It takes a reducer function (current state, action) => new state, and an initial value. It returns the current state and a dispatch function. Actions are usually objects with a type and optional payload.
This Hook shines in three scenarios: 1. State has multiple fields that depend on each other — e.g., a form where changing one field affects validation of another. 2. State transitions correspond to a state machine — loading → success → error. 3. You want to unit test state logic without rendering a component.
A common gotcha: mutating state inside the reducer. Even though the reducer is a pure function, if you write state.field = value and return state, React sees the same reference and skips the re-render. You must return a new object every time.
Another one: calling dispatch inside a loop without batching into a single action. Each dispatch triggers a re-render (even if batched later), causing intermediate renders. For bulk updates, dispatch one action that updates everything at once.
Also, useReducer doesn't give you any performance benefit over useState. Both cause the same re-render cycle. The benefit is readability and testability, not speed.
return. A common mistake is to write const reducer = (state, action) => ({ ...state }) which is correct, but forgetting the parentheses causes syntax errors.When to Use Each: useState vs useReducer
The choice isn't about performance — both Hooks work identically under the hood. It's about clarity and maintainability. Use useState for independent, simple values. Use useReducer when you need to orchestrate multiple sub-values or when the logic for updating state is non-trivial.
A good heuristic: if you have more than three useState calls in a component, or if any setState callback involves branching (if/else), switch to useReducer. Also, if the state naturally maps to a state machine (e.g., 'idle' -> 'loading' -> 'success' -> 'error'), useReducer makes the transitions explicit and prevents illegal states.
Don't over-engineer. A toggle button with a boolean useState is perfect. A form with twenty fields and cross-validation is not. Start with useState, and refactor to useReducer when the component grows. Force yourself to write the reducer and action types before the component — it forces you to think about states first, which leads to fewer bugs.
Sharing State Across Components (Lifting State and Context)
When two sibling components need the same data, the simplest answer is lifting state up: move the state to their nearest common ancestor and pass it down via props. This works well for two or three levels. Beyond that, prop drilling becomes a maintenance nightmare — you pass props through components that don't use them, just so a deep child can get access.
React Context solves this by letting you provide a value at one level and consume it anywhere below. It's a dependency injection system, not a state manager. The actual state still lives in a parent component (or in a custom Hook). Context just makes that state available without prop drilling.
The biggest mistake with Context: putting too much state into one context. When any part of the context value changes, all consumers re-render — even if they only use the unchanged part. This kills performance, especially with frequently updating data like form inputs. Solution: split contexts by domain (AuthContext, ThemeContext, UserContext) and memoise the value with useMemo so that only actual changes cause re-renders.
Another approach: use libraries like Zustand or Jotai that use external stores and subscriptions, avoiding the mass re-render problem altogether. For medium apps, Context + useReducer is sufficient; for large apps, consider a dedicated state library.
- The actual state lives in the provider component (e.g., ThemeProvider).
- Every time the provider re-renders, all consumers re-render unless the value reference is stable.
- Use useMemo to stabilise the value object — the consumer only re-renders if the actual data changes.
State Management Best Practices and Anti-Patterns
After years of fixing state-related bugs in production, a few patterns stand out as non-negotiable.
Keep state as low as possible. Don't lift state to the top of the app unless multiple remote components need it. Every unnecessary lift increases re-render scope. If only two siblings need data, lift to their parent, not to the root.
Don't store derived state. If a value can be computed from existing state (e.g., full name from first and last), use useMemo instead of a separate useState. Duplicate state is the #1 cause of sync bugs.
Treat state as immutable. Always create new objects/arrays. Libraries like Immer can help with deeply nested structures without the spread horror.
Use custom Hooks to encapsulate state logic. Extract useReducer + actions into a custom Hook. This makes the logic reusable, testable, and keeps components focused on rendering.
Prefer useReducer for anything with three or more fields. The boilerplate pays off when you need to add a new action or debug a transition.
Avoid prop drilling with a single Context — use multiple contexts or a state library. Your future self will thank you.
produce() to enforce immutability automatically.Context API: The Global State That Bites Back
You need a value accessible from 20 components. No prop drilling. The junior reaches for Context. Fine—until every consumer re-renders on every state change. Context is not a state management solution. It's a dependency injection mechanism. The value you pass triggers re-renders for every subscriber, even if they only read a single property. That's why your app feels sluggish when a dropdown updates global theme but also triggers a data grid to repaint. Break the value into separate contexts for separate concerns. Memoize the provider value with useMemo. Keep unrelated state in different providers. Context works great for auth tokens, theme colors, locale strings—things that change rarely. Not for real-time form data or high-frequency UI updates. Your production app will thank you.
When State Scales Beyond Hooks: Going Redux (And When Not To)
You've got async workflows. Optimistic updates. Normalized data that three teams touch. useState and Context start leaking complexity. That's when you bring Redux Toolkit. Why Redux? One centralized store with a single source of truth. Immer-based reducers let you write mutable-looking logic safely. createAsyncThunk handles loading states without boilerplate. But don't reach for Redux because 'it's industry standard.' Reach for it when your state shape grows beyond a single page, when you need time-travel debugging, or when data consistency across features matters more than file count. The cost: you add indirection. Actions dispatched, reducers listening, selectors querying. That's fine when the complexity is real. It's overhead when a component useState would suffice. For medium apps, start with hooks + Context. Migrate when your brain hurts tracing prop changes.
Stale Closure Caused User Data Loss in Production
- When a dispatch depends on current state, use the functional form (prev => ...) to avoid stale closures.
- Lint rule: always include state dependencies in useEffect, or extract the logic into the reducer.
- Test with rapid sequential updates — if state lags, you have a closure problem.
Add `console.log('state:', state)` before the return statement.Check if you're calling setState with a new reference or mutating the old one. Use `JSON.parse(JSON.stringify(state))` as a quick test.setState({...prev, key: newVal}).Key takeaways
Common mistakes to avoid
5 patternsMutating state directly instead of setting a new value
setState(prev => ({...prev, key: newVal})). For nested state, use structured clone or Immer library.Calling setState in a loop without functional update
setCount(prev => prev + 1) inside the loop. React batches state updates, so the closure captures the stale value.Overusing Context for global state without splitting
Putting derived state in multiple useState variables
Not extracting state logic into custom Hooks
Interview Questions on This Topic
Explain the difference between useState and useReducer. When would you use each?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
That's React.js. Mark it forged?
8 min read · try the examples if you haven't