ReactUpdating State Correctly

Updating State Correctly

Once you understand useState, the next step is learning how to update state safely. React has specific rules around state updates, and breaking them leads to subtle bugs — most commonly the "stale closure" problem where your handler reads an outdated value.

Always Use the Setter Function

Never mutate the state variable directly. Always call the setter function returned by useState:

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

// ✗ WRONG — direct mutation; React will not re-render
count = count + 1

// ✓ CORRECT — setter tells React to update and re-render
setCount(count + 1)
Warning
Mutating a state variable directly never triggers a re-render. The UI will appear frozen even though the JavaScript value changed. Always call the setter.
Functional Updates: prev => newValue

The setter function accepts either a new value or an updater function that receives the previous state and returns the next state:

JSX
// Direct value — fine when you don't depend on the current state:
setCount(0)
setCount(42)

// Updater function — always safe; receives the guaranteed latest value:
setCount(prev => prev + 1)
setCount(prev => prev * 2)
setIsOpen(prev => !prev)

The two forms look equivalent for a simple counter, but they behave differently when multiple state updates happen inside the same event handler.

The Stale Closure Problem

Because state values are snapshots captured at render time, reading count inside an event handler gives you the value from that render — not the most recent value after other updates have queued:

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

  // ✗ BUG — calling this three times in a row only adds 1, not 3
  function incrementThrice() {
    setCount(count + 1)  // count is 0, queues: set to 1
    setCount(count + 1)  // count is STILL 0, queues: set to 1
    setCount(count + 1)  // count is STILL 0, queues: set to 1
    // Result: count becomes 1, not 3
  }

  // ✓ FIX — functional update always builds on the truly latest value
  function incrementThriceCorrectly() {
    setCount(prev => prev + 1)  // prev=0, queues: set to 1
    setCount(prev => prev + 1)  // prev=1, queues: set to 2
    setCount(prev => prev + 1)  // prev=2, queues: set to 3
    // Result: count becomes 3 ✓
  }

  return (
    <div>
      <p>{count}</p>
      <button onClick={incrementThriceCorrectly}>+3</button>
    </div>
  )
}
Note
React processes functional updates in order, and each updater function receives the output of the previous one. This makes them safe to call multiple times inside the same event handler.
When Must You Use Functional Updates?
  • Multiple updates in one handler — when you call the setter more than once and each update depends on the last

  • setTimeout / setInterval callbacks — the callback closes over the state value at the time it was created; that value may be stale by the time the callback fires

  • Async functions — state read after an await may be stale

  • Custom hooks — hooks that expose a setter without direct access to the latest state should always use functional updates internally

Stale State in Async Callbacks

JSX
function AutoIncrement() {
  const [count, setCount] = useState(0)

  function startTimer() {
    // ✗ BUG — 'count' is captured at the time startTimer runs
    // Every tick will set count to 1 (0 + 1), not increment continuously
    setInterval(() => {
      setCount(count + 1)
    }, 1000)

    // ✓ FIX — functional update always has the latest value
    setInterval(() => {
      setCount(prev => prev + 1)
    }, 1000)
  }

  return <button onClick={startTimer}>{count}</button>
}
React Batches State Updates

React does not re-render after every single setState call. It batches multiple updates that happen in the same synchronous event handler and applies them together before a single re-render. This is an important performance optimization:

JSX
function Form() {
  const [name, setName]   = useState('')
  const [email, setEmail] = useState('')
  const [age, setAge]     = useState(0)

  function resetAll() {
    setName('')     // \
    setEmail('')    //  } All three are batched into ONE re-render
    setAge(0)       // /
  }

  return (/* ... */)
}
Tip
Since React 18, automatic batching works across all async contexts too — including `setTimeout`, Promises, and native event handlers. This means you can call multiple setters in an async function and React will batch them into a single re-render.
Replacing vs Merging State

Unlike this.setState in class components, the useState setter replaces the state entirely — it does not merge. When you have an object in state, you must explicitly spread the previous fields:

JSX
const [user, setUser] = useState({ name: 'Alice', age: 28, role: 'dev' })

// ✗ WRONG — this replaces the whole object, losing 'age' and 'role'
setUser({ name: 'Bob' })
// user is now: { name: 'Bob' } — age and role are gone!

// ✓ CORRECT — spread the previous state, then override the changed field
setUser(prev => ({ ...prev, name: 'Bob' }))
// user is now: { name: 'Bob', age: 28, role: 'dev' } ✓
Practical Pattern: Reset to Initial State

JSX
const initialForm = { name: '', email: '', message: '' }

function ContactForm() {
  const [form, setForm] = useState(initialForm)

  function handleChange(field, value) {
    setForm(prev => ({ ...prev, [field]: value }))
  }

  function handleReset() {
    setForm(initialForm)  // reset to the original object
  }

  function handleSubmit(e) {
    e.preventDefault()
    submitToServer(form)
    handleReset()
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={form.name}
        onChange={(e) => handleChange('name', e.target.value)}
        placeholder="Name"
      />
      <input
        value={form.email}
        onChange={(e) => handleChange('email', e.target.value)}
        placeholder="Email"
      />
      <textarea
        value={form.message}
        onChange={(e) => handleChange('message', e.target.value)}
        placeholder="Message"
      />
      <button type="submit">Send</button>
      <button type="button" onClick={handleReset}>Reset</button>
    </form>
  )
}
  • Always call the setter — never mutate state directly

  • Use functional updates (prev => newValue) when the new value depends on the previous value

  • Functional updates are required when making multiple updates in one handler, inside timers, or after async await

  • State updates are batched — React applies them all at once before re-rendering

  • The setter replaces state — spread the previous value when updating a field in an object