| |

React 17 ⚛️ Updating State Based on Previous State

When a state update depends on the previous value, React provides a functional form of the setter that receives the current state as its argument. This is not a stylistic preference; it is the correct way to express the update when the new value is derived from the old one. The direct form — reading the state variable and passing the computed value to the setter — captures the value from the render in which the handler was created, which may be stale by the time the update runs. The functional form receives the latest value from React, and it is correct in every situation.

The problem the functional form solves is subtle, which is why it is the source of so many bugs. In a simple counter that increments once per click, the direct form works. The handler reads count, adds one, and passes the result. The next render has the new value. But when the handler calls the setter twice, or when the setter is called from a timeout, a promise, or a native event listener, the direct form uses the value from the render that created the closure, not the value at the moment the update runs. The functional form eliminates the discrepancy by letting React supply the current value at the time of the update.

This chapter covers why the previous state matters, the functional form of the setter, the stale closure problem, the multiple-update problem, batching and its interaction with functional updates, and the patterns that use the functional form for numbers, strings, booleans, arrays, and objects.

Key point: Use the functional form setState(prev => newValue) whenever the new value depends on the previous value. The functional form receives the latest state from React and is correct when the setter is called multiple times, from asynchronous callbacks, or after other updates. The direct form captures the state from the render in which the handler was created, which may be stale.


Why updating based on previous state matters

The stale closure problem. A function defined inside a component captures the variables from the render in which it was created. If the function is called later — after the state has changed and the component has re-rendered — the captured variables hold the values from the earlier render. The direct form of the setter uses the captured value, which may be stale. The functional form avoids the capture by receiving the value at the time of the update.

The multiple-update problem. When the setter is called twice in the same handler with the direct form, both calls use the same value, because the state variable has not changed within the render. The result is one increment instead of two. The functional form receives the result of the first update when the second is processed, so both updates apply.

The batching problem. React batches multiple state updates into a single re-render. The batching is what makes the multiple-update problem possible: the state variable does not change between the two setter calls, because the re-render happens after both calls. The functional form is designed for this scenario.

The asynchronous problem. A setter called from a timeout, a promise, or an event listener runs after the render that created the callback. The direct form uses the state from that render, which may be several updates old. The functional form uses the current value.

The correctness problem. The direct form is correct when the new value does not depend on the previous value. It is incorrect when the new value does depend on the previous value and the update may run in a context where the state has changed. The functional form is always correct, so the rule is simple: use it whenever the new value is derived from the old one.


a. The functional form of the setter

The setter returned by useState accepts either a new value or a function. The function receives the previous state and returns the new state.

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

// Direct form
setCount(count + 1);

// Functional form
setCount((prev) => prev + 1);

Both forms work in the simple case. The functional form is the one that remains correct when the update is deferred or repeated.

The function can be inline or named:

setCount((prev) => prev + 1);
setCount(increment);
function increment(prev) {
  return prev + 1;
}

The function must return the new state. It must not mutate the previous state, because the previous state is the value React holds and may reuse.


b. The stale closure problem

A closure captures the variables from the scope in which it was created. In a component, the handler is created during the render, and it captures the state variable from that render.

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

  const handleClick = () => {
    setCount(count + 1);
    console.log(count);  // the value from this render
  };

  return <button onClick={handleClick}>{count}</button>;
}

The handleClick closure captures count from the render in which it was created. When the button is clicked, the handler uses that captured value. If the state has changed since the render, the captured value is stale.

In the simple counter, the handler runs once and the component re-renders. The next click uses the new handler from the new render, which captures the new value. The direct form works because the handler is recreated on every render.

The problem appears when the handler is not recreated, or when the update is deferred.


c. Multiple updates in one handler

When the setter is called twice with the direct form, both calls use the same value.

const handleClick = () => {
  setCount(count + 1);
  setCount(count + 1);
  // count increases by 1, not 2
};

The count variable is the same for both calls, because it is the value from the current render. The two setters are batched into one re-render, and the second overwrites the first with the same value.

The functional form fixes this:

const handleClick = () => {
  setCount((c) => c + 1);
  setCount((c) => c + 1);
  // count increases by 2
};

React processes the two functional updates in sequence. The first receives the current value and returns the incremented value. The second receives the result of the first and returns the incremented value again. The final state reflects both updates.

This is the canonical example of why the functional form exists. It is not about style; it is about correctness when updates are batched and repeated.


d. Batching and functional updates

React batches state updates that occur in the same event handler, and in React 18 and later, in promises, timeouts, and native event handlers. The batching means that the component re-renders once with the final state, and the intermediate values are not rendered.

The batching is what makes the multiple-update problem possible. Without batching, each setter would trigger a re-render, and the state variable would be updated between the calls. With batching, the state variable is the same for all the calls in the batch.

The functional form is the mechanism that lets each update in a batch see the result of the previous one. The direct form sees the state from the render that created the handler, which is the state before the batch.

// Direct: all calls see the pre-batch value
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// Result: count increases by 1

// Functional: each call sees the result of the previous
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);
// Result: count increases by 3

The functional form is the correct choice when the new value depends on the old one, regardless of whether the handler calls the setter once or multiple times.


e. Functional updates in asynchronous callbacks

A setter called from a timeout, a promise, or a native event listener runs after the render that created the callback. The direct form uses the state from that render, which may be stale.

const handleClick = () => {
  setTimeout(() => {
    setCount(count + 1);  // uses count from the render
  }, 1000);
};

If the state has changed in the second between the click and the timeout, the update uses the old value. The functional form avoids this:

const handleClick = () => {
  setTimeout(() => {
    setCount((c) => c + 1);  // uses the latest value
  }, 1000);
};

The functional form receives the current state at the time the timeout runs, not the state at the time the timeout was created. This is the correct behavior.

The same applies to promises, intervals, and event listeners that are registered once and called many times.

useEffect(() => {
  const id = setInterval(() => {
    setCount((c) => c + 1);  // correct
  }, 1000);
  return () => clearInterval(id);
}, []);

The interval callback is created once, when the effect runs. The direct form would use the state from the render in which the effect was created, which is 0. The functional form uses the current value on every tick.


f. Functional updates for different types

The functional form works for any type. The function receives the previous value and returns the new value.

Number.

setCount((c) => c + 1);
setCount((c) => c * 2);

String.

setText((t) => t + '!');
setText((t) => t.toUpperCase());

Boolean.

setIsOpen((open) => !open);

Array.

setItems((items) => [...items, newItem]);
setItems((items) => items.filter((i) => i.id !== id));
setItems((items) => items.map((i) => i.id === id ? updated : i));

Object.

setUser((user) => ({ ...user, name: 'Alice' }));
setForm((form) => ({ ...form, [field]: value }));

The array and object cases are where the functional form is most valuable, because the new value is derived from the old one by adding, removing, or updating an element. The functional form ensures that the update uses the current collection, not a stale one.


Complete Example Session

// ============================================
// PART 1: DIRECT UPDATE — WORKS ONCE
// ============================================
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>{count}</button>
  );
}
// ============================================
// PART 2: DIRECT UPDATE — STALE ON MULTIPLE CALLS
// ============================================
function Counter() {
  const [count, setCount] = useState(0);

  const incrementTwice = () => {
    setCount(count + 1);
    setCount(count + 1);
    // Result: count increases by 1
  };

  return <button onClick={incrementTwice}>{count}</button>;
}
// ============================================
// PART 3: FUNCTIONAL UPDATE — CORRECT
// ============================================
function Counter() {
  const [count, setCount] = useState(0);

  const incrementTwice = () => {
    setCount((c) => c + 1);
    setCount((c) => c + 1);
    // Result: count increases by 2
  };

  return <button onClick={incrementTwice}>{count}</button>;
}
// ============================================
// PART 4: TOGGLE BOOLEAN
// ============================================
function Toggle() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <button onClick={() => setIsOpen((open) => !open)}>
      {isOpen ? 'Close' : 'Open'}
    </button>
  );
}
// ============================================
// PART 5: STRING CONCATENATION
// ============================================
function Chat() {
  const [text, setText] = useState('');

  const addExclamation = () => {
    setText((t) => t + '!');
  };

  return <button onClick={addExclamation}>{text}</button>;
}
// ============================================
// PART 6: ARRAY — APPEND
// ============================================
function TodoList() {
  const [todos, setTodos] = useState([]);
  const [input, setInput] = useState('');

  const addTodo = () => {
    setTodos((prev) => [...prev, { id: Date.now(), text: input }]);
    setInput('');
  };

  return (
    <div>
      <input value={input} onChange={(e) => setInput(e.target.value)} />
      <button onClick={addTodo}>Add</button>
    </div>
  );
}
// ============================================
// PART 7: ARRAY — REMOVE
// ============================================
const removeTodo = (id) => {
  setTodos((prev) => prev.filter((t) => t.id !== id));
};
// ============================================
// PART 8: ARRAY — UPDATE
// ============================================
const toggleTodo = (id) => {
  setTodos((prev) =>
    prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
  );
};
// ============================================
// PART 9: OBJECT — SPREAD
// ============================================
function Profile() {
  const [user, setUser] = useState({ name: 'Alice', age: 30 });

  const updateName = (newName) => {
    setUser((prev) => ({ ...prev, name: newName }));
  };

  return <button onClick={() => updateName('Bob')}>{user.name}</button>;
}
// ============================================
// PART 10: ASYNC CALLBACK WITH FUNCTIONAL UPDATE
// ============================================
function DelayedCounter() {
  const [count, setCount] = useState(0);

  const incrementLater = () => {
    setTimeout(() => {
      setCount((c) => c + 1);
    }, 1000);
  };

  return <button onClick={incrementLater}>{count}</button>;
}

These ten parts cover the direct update that works once, the direct update that is stale on multiple calls, the functional update that is correct, toggling a boolean, string concatenation, array append, array remove, array update, object spread, and an async callback with a functional update.


Quick Reference

Setter Forms

FormSyntaxWhen to use
DirectsetCount(5)New value is constant
FunctionalsetCount((c) => c + 1)New value depends on old

When to Use the Functional Form

SituationRecommendation
New value depends on oldFunctional
Multiple updates in one handlerFunctional
Update in async callbackFunctional
Update in intervalFunctional
New value is constantDirect

Functional Updates by Type

TypeExample
NumbersetCount((c) => c + 1)
StringsetText((t) => t + '!')
BooleansetIsOpen((o) => !o)
Array appendsetItems((i) => [...i, item])
Array removesetItems((i) => i.filter(...))
Array updatesetItems((i) => i.map(...))
ObjectsetUser((u) => ({ ...u, name: 'Alice' }))

Direct vs Functional

ScenarioDirectFunctional
One update, constant valueCorrectCorrect
One update, derived valueMay be staleCorrect
Multiple updates in one handlerStaleCorrect
Update in async callbackMay be staleCorrect

Best Practices

✅ Do This:

// Use the functional form when depending on the previous value
setCount((c) => c + 1);

// Use the functional form for multiple updates
setCount((c) => c + 1);
setCount((c) => c + 1);

// Use the functional form in async callbacks
setTimeout(() => setCount((c) => c + 1), 1000);

// Use the functional form for arrays
setItems((items) => [...items, newItem]);

// Use the functional form for objects
setUser((user) => ({ ...user, name: 'Alice' }));

❌ Don’t Do This:

// Use the direct form multiple times
setCount(count + 1);
setCount(count + 1);  // ❌ increments by 1

// Use the direct form in an interval
setInterval(() => setCount(count + 1), 1000);  // ❌ stale

// Rely on the state value immediately after setting
setCount(count + 1);
console.log(count);  // ❌ old value

// Mutate the previous state inside the function
setItems((items) => { items.push(x); return items; });  // ❌ mutation

// Forget to return the new value
setItems((items) => { [...items, x]; });  // ❌ returns undefined

Common Pitfalls

PitfallWhy It HappensFix
Multiple updates collapseUsed the direct formUse the functional form
Stale value in intervalClosure captured old valueUse the functional form
Stale value in timeoutSameUse the functional form
State resets to initialMutation broke the referenceCreate a new array or object
Function returns undefinedMissing returnReturn the new value
State not updatingMutated the previous stateCopy and modify

Real-World Examples

1. Increment

setCount((c) => c + 1);

2. Increment Twice

setCount((c) => c + 1);
setCount((c) => c + 1);

3. Toggle

setIsOpen((o) => !o);

4. Append to Array

setItems((items) => [...items, newItem]);

5. Remove from Array

setItems((items) => items.filter((i) => i.id !== id));

6. Update in Array

setItems((items) => items.map((i) => (i.id === id ? updated : i)));

7. Update Object

setUser((user) => ({ ...user, name: 'Alice' }));

8. Update Form Field

setForm((form) => ({ ...form, [field]: value }));

9. Interval Increment

setInterval(() => setCount((c) => c + 1), 1000);

10. Timeout Increment

setTimeout(() => setCount((c) => c + 1), 1000);

Visual

Direct vs Functional

┌──────────────────────────────────────────────────────────────┐
│  DIRECT:                                                     │
│  setCount(count + 1);                                        │
│  setCount(count + 1);                                        │
│  └── Both use count from this render                         │
│  └── Result: +1                                              │
│                                                              │
│  FUNCTIONAL:                                                 │
│  setCount((c) => c + 1);                                     │
│  setCount((c) => c + 1);                                     │
│  └── First receives 0, returns 1                             │
│  └── Second receives 1, returns 2                            │
│  └── Result: +2                                              │
└──────────────────────────────────────────────────────────────┘

Stale Closure

┌──────────────────────────────────────────────────────────────┐
│  Render 1: count = 0                                         │
│  └── Handler captures count = 0                              │
│                                                              │
│  User clicks: setCount(count + 1) → 1                        │
│                                                              │
│  Render 2: count = 1                                         │
│  └── New handler captures count = 1                          │
│                                                              │
│  In a simple counter, the handler is recreated each render,  │
│  so the direct form works.                                   │
│                                                              │
│  But in a setTimeout created in render 1:                    │
│  └── The closure still captures count = 0                    │
│  └── The direct form uses the stale value                    │
└──────────────────────────────────────────────────────────────┘

Batching

┌──────────────────────────────────────────────────────────────┐
│  const handleClick = () => {                                 │
│    setCount((c) => c + 1);  ← queued                         │
│    setCount((c) => c + 1);  ← queued                         │
│  };                                                          │
│                                                              │
│  React processes the queue:                                  │
│  1. First update: receives 0, returns 1                      │
│  2. Second update: receives 1, returns 2                     │
│  3. Re-render with count = 2                                 │
│                                                              │
│  The functional form lets each update see the previous one.  │
└──────────────────────────────────────────────────────────────┘

Async Callback

┌──────────────────────────────────────────────────────────────┐
│  Render 1: count = 0                                         │
│  └── setTimeout created, captures count = 0                  │
│                                                              │
│  User clicks again: count = 1                                │
│                                                              │
│  Timeout fires:                                              │
│  └── Direct form: setCount(0 + 1) = 1 (stale)                │
│  └── Functional: setCount(c => c + 1) = 2 (correct)          │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Functional formsetState((prev) => newValue)
Direct formsetState(newValue)
Use functional whenNew value depends on old
Use functional whenMultiple updates in one handler
Use functional whenUpdate in async callback
Use functional whenUpdate in interval or timeout
BatchingMultiple setters, one re-render
Stale closureCaptured value from the render
Return requiredThe function must return the new value
MutationNever mutate the previous state

Key takeaways:

  • Use the functional form when the new value depends on the previous value. The function receives the latest state from React, so it is correct even when the update is deferred or repeated.
  • The direct form captures the state from the render. If the handler was created in a render with an older state, the direct form uses that older value. The functional form avoids the capture.
  • Multiple updates in one handler require the functional form. With the direct form, both calls use the same value, and the result is one update instead of two. With the functional form, each update receives the result of the previous one.
  • Batching is what makes the multiple-update problem possible. React batches the updates into one re-render, so the state variable does not change between the calls. The functional form is designed for this scenario.
  • Async callbacks require the functional form. A timeout, promise, or interval callback captures the state from the render in which it was created. The functional form uses the current value at the time the callback runs.
  • The functional form works for every type. Numbers, strings, booleans, arrays, and objects all use the same pattern. For arrays and objects, the function returns a new value derived from the previous one.
  • Never mutate the previous state. The function must return a new value. Mutating the previous state and returning it breaks React’s reference comparison and may cause the update to be skipped.

Remember: The functional form of the setter is not a stylistic preference; it is the correct way to express an update that depends on the previous state. The direct form works in the simple case, but it fails when the update is deferred, repeated, or batched. The functional form receives the latest value from React and is correct in every situation. The rule is simple: if the new value depends on the old one, use the functional form. This applies to numbers, strings, booleans, arrays, and objects, and it applies to every context — event handlers, timeouts, promises, and intervals. Understanding the difference between the two forms is understanding how React state updates actually work.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!