Mid-level 8 min · March 05, 2026

React State Management — Stale Closure Data Loss

Users' form fields revert after each change due to stale closures.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
Production
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
✦ Definition~90s read
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.

Imagine your React app is a restaurant.

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.

Plain-English First

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.

io/thecodeforge/ReactStateConcept.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Package naming: io.thecodeforge
package io.thecodeforge;

public class ReactStateConcept {
    // This is a conceptual Java illustration — not real React.
    // The point: state is an explicit update trigger.
    private String value;

    public void setState(String newValue) {
        this.value = newValue;
        // In React, setting value triggers a re-render.
        // In vanilla Java, this just updates a field silently.
    }

    public String getState() {
        return this.value;
    }

    public static void main(String[] args) {
        ReactStateConcept app = new ReactStateConcept();
        app.setState("React State Management");
        System.out.println(app.getState());
    }
}
Output
React State Management
Forge Tip
Type this code yourself rather than copy-pasting. The muscle memory of writing it will help it stick.
Production Insight
The Java example above is an analogy — not production React.
In real React apps, state is managed with Hooks inside function components.
Don't use class-based state in new code; Hooks are cleaner and safer.
Key Takeaway
State is a component's memory — React forgets everything between renders.
When state changes, only components that use it re-render.
Never mutate state; always replace with a new reference.
React State Management: Stale Closure Data Loss THECODEFORGE.IO React State Management: Stale Closure Data Loss Flow from local state to global state with common pitfalls useState Hook Local component state with setter function useReducer Hook Complex state logic with reducer function Lifting State Up Share state via common ancestor Context API Global state without prop drilling Redux Library Predictable state container for large apps ⚠ Stale closure in useEffect or callbacks Always include dependencies or use functional updates THECODEFORGE.IO
thecodeforge.io
React State Management: Stale Closure Data Loss
React State Management

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.

Counter.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import React, { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // Always use functional update for dependent updates
    setCount(prev => prev + 1);
    setCount(prev => prev + 1); // Both are batched, count becomes +2
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
    </div>
  );
}
Output
Clicking the button increments count by 2 each time due to batching.
Mental Model: The Value Box
  • 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.
Production Insight
Setting state to a new object with the same contents still triggers a re-render because the reference differs.
Best practice: only create a new object when you actually change a field.
Overwriting state unnecessarily causes wasted re-renders and performance degradation in large lists.
Key Takeaway
useState gives you a value and a setter.
Use functional updates when the next value depends on the previous.
Never mutate state — always replace it.
Choose between useState and useReducer
IfState is a single primitive value (boolean, number, short string)
UseUse useState — it's simpler and less boilerplate.
IfState is an object with multiple fields that change independently
UseUse useReducer — it consolidates logic and prevents fragmented setState calls.
IfState transitions depend on current state (e.g., counters, toggles)
UseEither works, but useReducer makes multi-step transitions more readable.
IfState logic is complex and reused across components
UseUseReducer + custom Hook gives you a testable state machine.

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.

FormReducer.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import React, { useReducer } from 'react';

const initialState = {
  name: '',
  email: '',
  age: 0,
  submitted: false
};

function formReducer(state, action) {
  switch (action.type) {
    case 'SET_FIELD':
      return { ...state, [action.field]: action.value };
    case 'RESET':
      return initialState;
    case 'SUBMIT':
      return { ...state, submitted: true };
    default:
      return state;
  }
}

function Form() {
  const [state, dispatch] = useReducer(formReducer, initialState);

  const handleChange = (e) => {
    dispatch({
      type: 'SET_FIELD',
      field: e.target.name,
      value: e.target.value
    });
  };

  return (
    <form>
      <input name="name" value={state.name} onChange={handleChange} />
      <input name="email" value={state.email} onChange={handleChange} />
      <button type="button" onClick={() => dispatch({ type: 'SUBMIT' })}>
        Submit
      </button>
    </form>
  );
}
Output
Each keystroke dispatches SET_FIELD, updating the relevant field immutably.
Watch Out: Implicit Returns in Arrow Functions
If your reducer uses an arrow function with curly braces, you need an explicit return. A common mistake is to write const reducer = (state, action) => ({ ...state }) which is correct, but forgetting the parentheses causes syntax errors.
Production Insight
Unit test every reducer — it's pure logic without component overhead.
Dispatch calls in event handlers can cause multiple re-renders if not batched; React 18 batches dispatches inside timeouts and promises as well.
If you need to dispatch multiple actions in sequence, consider batching them into a single action to avoid intermediate renders.
Key Takeaway
useReducer makes state transitions explicit and testable.
Keep reducers pure — no side effects, no async, no API calls.
If you find yourself writing complex setState logic, switch to useReducer.

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.

Production Insight
In production, you'll rarely need to switch from useState to useReducer retroactively — it's a design decision.
If you start with useState and the component grows, refactor to useReducer inside a custom Hook to keep the component clean.
Pro tip: always write the reducer and action types before the component — it forces you to think about states first.
Key Takeaway
Start with useState, refactor to useReducer when complexity demands it.
The correct choice is the one that makes your code readable six months later.

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.

ThemeContext.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import React, { createContext, useContext, useState, useMemo } from 'react';

const ThemeContext = createContext(null);

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  const toggleTheme = () =>
    setTheme(prev => (prev === 'light' ? 'dark' : 'light'));

  // Memoize the context value to prevent re-renders of all consumers
  const value = useMemo(() => ({ theme, toggleTheme }), [theme]);

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
  return ctx;
}
Output
Any component wrapping ThemeProvider can access theme and toggleTheme via useTheme().
Mental Model: Context as an Injection Portal
  • 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.
Production Insight
If you have a context value that changes frequently (e.g., form field values), every consumer re-renders on every keystroke.
Solution: split contexts or use separate providers for stable vs. volatile state.
Alternatively, use libraries like Zustand or Jotai that avoid the re-render problem by using external stores and subscriptions.
Key Takeaway
Lift state when components are close. Use Context to avoid deep prop drilling.
Always memoise context values. Split contexts by domain.

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.

useFormState.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { useReducer } from 'react';

const initialState = { name: '', email: '', errors: {} };

function formReducer(state, action) {
  switch (action.type) {
    case 'SET_FIELD':
      return { ...state, [action.field]: action.value };
    case 'SET_ERRORS':
      return { ...state, errors: action.errors };
    case 'RESET':
      return initialState;
    default:
      return state;
  }
}

export function useFormState() {
  const [state, dispatch] = useReducer(formReducer, initialState);

  const setField = (field, value) => {
    dispatch({ type: 'SET_FIELD', field, value });
  };

  const setErrors = (errors) => {
    dispatch({ type: 'SET_ERRORS', errors });
  };

  const reset = () => dispatch({ type: 'RESET' });

  return { state, setField, setErrors, reset };
}
Output
A reusable custom Hook that encapsulates a form's state logic using useReducer.
Pro Tip
Write your reducer and action types before you write the component. It forces you to think about all possible state transitions upfront — fewer 'oh wait, I forgot that case' moments.
Production Insight
Duplicating derived state (e.g., storing fullName alongside firstName and lastName) causes hard-to-find sync bugs.
If you mutate state accidentally, use Immer's produce() to enforce immutability automatically.
Always wrap Context values with useMemo — a new object every render will re-render every consumer.
Key Takeaway
Keep state low, derived state computed, state immutable.
Extract into custom Hooks for reusability and testability.

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.

ThemeProvider.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// io.thecodeforge
import { createContext, useContext, useState, useMemo } from 'react';

const ThemeContext = createContext();
const FontSizeContext = createContext();

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  const [fontSize, setFontSize] = useState(16);

  // Memoize to prevent re-renders on unrelated state changes
  const themeValue = useMemo(() => ({ theme, setTheme }), [theme]);
  const fontSizeValue = useMemo(() => ({ fontSize, setFontSize }), [fontSize]);

  return (
    <ThemeContext.Provider value={themeValue}>
      <FontSizeContext.Provider value={fontSizeValue}>
        {children}
      </FontSizeContext.Provider>
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) throw new Error('useTheme must be within ThemeProvider');
  return context;
}

export function useFontSize() {
  const context = useContext(FontSizeContext);
  if (!context) throw new Error('useFontSize must be within FontSizeProvider');
  return context;
}
Output
Only components consuming useTheme re-render when theme changes. FontSize consumers are isolated.
Production Trap:
Putting a state object with nested properties into Context without memoization causes every consumer to re-render on any property change. Split contexts by domain.
Key Takeaway
Context is for dependency injection, not state management. Memoize the provider value or pay per-frame re-renders.

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.

userSlice.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// io.thecodeforge
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

export const fetchUser = createAsyncThunk(
  'users/fetchById',
  async (userId, { rejectWithValue }) => {
    try {
      const res = await fetch(`/api/users/${userId}`);
      if (!res.ok) throw new Error('User not found');
      return res.json();
    } catch (err) {
      return rejectWithValue(err.message);
    }
  }
);

const userSlice = createSlice({
  name: 'user',
  initialState: { entity: null, status: 'idle', error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => { state.status = 'loading'; })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.entity = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.payload;
      });
  },
});

export default userSlice.reducer;
Output
Dispatch fetchUser(123) in your component. Status auto-handles loading, success, and failure states. No manual flag management.
Reality Check:
Redux Toolkit eliminated boilerplate but not mental overhead. Use selectors with createSelector to avoid re-computation. Profile before optimizing.
Key Takeaway
Redux is for complex, cross-feature state. Not for every app. Start lean, scale into Redux when you feel the pain.
● Production incidentPOST-MORTEMseverity: high

Stale Closure Caused User Data Loss in Production

Symptom
Users fill in a form with 10 fields. After changing field A, then field B, field A's value reverts to its original. Only the last change is persisted.
Assumption
The team assumed useReducer automatically captures the latest state. They passed an outdated state variable from a closure.
Root cause
The reducer dispatch was called inside a useEffect with an empty dependency list. The action creator captured the initial state, not the current. Every subsequent dispatch used the stale snapshot.
Fix
Either include the state variable in the effect dependencies, or use functional update form: dispatch(prev => ({...prev, field: newValue})). The second approach avoids the dependency altogether.
Key lesson
  • 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.
Production debug guideSymptom-to-action guide for common state problems in production4 entries
Symptom · 01
Component doesn't re-render after state update
Fix
Check if you mutated the state object directly. Use setState with a new reference. Log in the render body to confirm state is called.
Symptom · 02
setState calls seem to be ignored (no change)
Fix
Verify the state variable name and that setState is the correct dispatcher. Check for missing useEffect dependency if the update is async.
Symptom · 03
Re-renders are too frequent (performance issue)
Fix
Use React.memo or useMemo to prevent unnecessary re-renders. Check that you're not creating new objects/arrays inline in the render.
Symptom · 04
State is shared unexpectedly between components
Fix
Confirm state is lifted correctly. Each component that calls useState gets its own instance. Use React DevTools to inspect component trees.
★ Quick State Debug Cheat SheetInstant commands and checks for common state debugging scenarios
State update doesn't appear in UI
Immediate action
Check the React DevTools Components tab — does the state show the new value? If yes, problem is in render. If no, problem is in update logic.
Commands
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.
Fix now
Replace direct mutation with spread operator: setState({...prev, key: newVal}).
useEffect runs too often or not enough+
Immediate action
Log the dependency values inside the effect to see if they're actually changing.
Commands
Add `console.log('deps:', dep1, dep2)` at the top of the effect.
Check for objects/arrays as dependencies — they change every render. Consider memoising with useMemo.
Fix now
If the effect should run only once, pass empty dependency array. If it depends on state, list that state explicitly.
Reducer dispatch doesn't update state as expected+
Immediate action
Log the action and previous state inside the reducer to verify the reducer logic.
Commands
Add `console.log('Reducer:', prevState, action)` at the beginning of the reducer function.
Check that you're returning a new object, not mutating the previous state.
Fix now
If using switch, ensure default returns state unchanged. Never mutate action.payload.
React State Management Tools Compared
ToolBest forTrade-off
useStateSimple local stateBecomes messy if state logic grows
useReducerComplex state with multiple transitionsMore boilerplate upfront
Context + useReducerGlobal state for medium appsRe-renders all consumers on any change
Redux / Zustand / JotaiLarge apps with complex state interactionsAdds dependencies and patterns overhead

Key takeaways

1
useState for simple local state; useReducer for complex state transitions
2
Never mutate state
always return a new reference
3
Context helps avoid prop drilling but can cause performance issues if overused
4
Extract state logic into custom Hooks for testability and reuse
5
Always use functional updates when the new state depends on the old
6
Start with useState, refactor to useReducer when complexity demands it

Common mistakes to avoid

5 patterns
×

Mutating state directly instead of setting a new value

Symptom
Component doesn't re-render after calling setState or dispatch, even though the state object appears updated in the debugger.
Fix
Always replace state with a new object/array. For objects: setState(prev => ({...prev, key: newVal})). For nested state, use structured clone or Immer library.
×

Calling setState in a loop without functional update

Symptom
Counter incrementing by 1 instead of the expected number (e.g., adding 5 items only adds 1).
Fix
Use the functional update form: 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

Symptom
All components re-render when a small part of the global state changes, causing performance issues.
Fix
Split contexts by domain. Use separate providers for user data, UI state, etc. Memoise the context value to avoid passing new references on every render.
×

Putting derived state in multiple useState variables

Symptom
Two state variables that must stay in sync — updating one but forgetting the other causes inconsistencies.
Fix
Store only the source of truth. If the value can be computed from other state, use useMemo instead of useState. If multiple variables are tightly coupled, use a single useReducer.
×

Not extracting state logic into custom Hooks

Symptom
State logic duplicated across multiple components, making changes error-prone and testing difficult.
Fix
Move useReducer and related functions into a custom Hook. Export the state and action helpers. The component stays clean and the logic becomes reusable and testable.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the difference between useState and useReducer. When would you u...
Q02SENIOR
Why does React batch state updates? How does it affect code that uses mu...
Q03SENIOR
What is prop drilling and how does React Context solve it? What are the ...
Q04SENIOR
How would you manage global state in a large React application without u...
Q05SENIOR
Explain the concept of lifting state up and when it's better than using ...
Q01 of 05SENIOR

Explain the difference between useState and useReducer. When would you use each?

ANSWER
useState is a Hook for simple state that consists of a single value (number, string, boolean) or an object where each field is updated independently. It returns a pair: current state and setter. Use it when state transitions are straightforward. useReducer is for complex state logic where the next state depends on multiple previous values or involves a state machine pattern. It takes a reducer function and initial state, returning state and dispatch. The reducer centralises all state transition logic, making it testable and predictable. Rule of thumb: if you have more than three useState calls or a setState callback with complex logic, use useReducer.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is React State Management in simple terms?
02
Can I use both useState and useReducer in the same component?
03
Does useReducer replace Redux?
04
Why does React re-render when I call setState with the same value?
05
How do I decide between Context and a state library like Zustand?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
Verified
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's React.js. Mark it forged?

8 min read · try the examples if you haven't

Previous
React Components and Props
3 / 47 · React.js
Next
React Hooks — useState and useEffect