React – Updating State Correctly

July 6, 20264 min readUpdated 8/18/2026

Calling a state setter does not change the variable you are holding. It schedules a re-render, and the new value appears in the next one. Almost every confusing state bug comes from expecting otherwise.

State is a snapshot

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

  function handleClick() {
    setCount(count + 1);
    console.log(count);        // still 0 — even though we just "set" it to 1
  }

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

count is a const belonging to this render. Nothing can change it, and React does not try to. What setCount does is tell React "next time you render this component, hand it 1". The current render finishes with the value it started with.

This is not a quirk to work around; it is what makes React predictable. Everything a render sees is frozen for the duration of that render, so a handler cannot read a value that is half-updated.

The consequence: batching

function handleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
}

Starting from 0, this sets the count to 1, not 3. Every count in that function is the same frozen 0, so all three calls say "make it 1". React then performs one re-render for the whole handler, rather than three.

The updater function

Pass a function instead of a value and React calls it with the latest pending state, queuing your change on top of the previous one:

function handleClick() {
  setCount((c) => c + 1);
  setCount((c) => c + 1);
  setCount((c) => c + 1);       // now it really is 3
}

The rule: if the new value depends on the old one, use the updater form. It costs nothing and it is never wrong.

It matters most when the "old value" is not visible at the call site. The toast provider adds a toast, and three seconds later removes it — by which time toasts from the original render is long stale, and the array may hold entirely different messages:

const showToast = useCallback((message: string, variant: ToastVariant = 'success') => {
  const id = crypto.randomUUID();
  setToasts((current) => [...current, { id, message, variant }]);

  window.setTimeout(() => {
    setToasts((current) => current.filter((toast) => toast.id !== id));
  }, 3000);
}, []);

Written as setToasts([...toasts, newToast]) this would be broken in two ways: firing two toasts quickly would lose the first, and the timeout would restore a snapshot of the list from three seconds ago. The updater form has no such problem, and it is also why the useCallback around it can have an empty dependency array — it never reads toasts.

Same shape, toggling a topping on and off:

function toggleTopping(id: string) {
  setSelectedToppingIds((current) =>
    current.includes(id) ? current.filter((t) => t !== id) : [...current, id],
  );
}

Never mutate

React decides whether to re-render by comparing the new state to the old one by reference. Change an object in place and the reference is identical, so React concludes nothing happened.

// WRONG. The array is now different, but it is the SAME array, so nothing re-renders.
items.push(newItem);
setItems(items);

// Right — a new array.
setItems([...items, newItem]);

The mutating array methods to avoid: push, pop, shift, unshift, splice, sort, reverse. The last two catch people out because they read like they return a new array — they do not, they sort in place and return the same reference. Copy first: [...items].sort(…).

Objects

// WRONG
user.fullName = 'Folau';
setUser(user);

// Right
setUser({ ...user, fullName: 'Folau' });

Arrays of objects

This is the case that actually comes up, and the one worth memorising. Changing one item in a list means map — a new array, with a new object in one position and the originals everywhere else:

case 'SET_QUANTITY': {
  // Dropping to zero removes the line — it is what a user expects from a "−" button.
  if (action.payload.quantity <= 0) {
    return {
      ...state,
      items: state.items.filter((item) => item.lineId !== action.payload.lineId),
    };
  }
  return {
    ...state,
    items: state.items.map((item) =>
      item.lineId === action.payload.lineId
        ? { ...item, quantity: action.payload.quantity }
        : item,
    ),
  };
}

Read the map carefully: the matching item is replaced by a copy with a new quantity, and every other item is returned as is. Untouched items keep their identity, which is what lets React.memo skip re-rendering them.

The three operations, all non-mutating:

// add
items: [...state.items, action.payload]

// remove
items: state.items.filter((item) => item.lineId !== action.payload.lineId)

// update one
items: state.items.map((item) =>
  item.lineId === target ? { ...item, quantity: next } : item,
)

Nesting

Spread is shallow. Updating something two levels down means copying each level on the way:

setOrder({
  ...order,
  address: { ...order.address, city: 'Salt Lake City' },
});

If you are writing three levels of that, the state shape is the problem, not the syntax. Flatten it, or use a library like Immer that lets you write the mutation and produces the copy for you — Redux Toolkit bundles Immer for exactly this reason.

Why not just mutate and force an update?

Because immutability is what buys you the rest of React. Reference comparison is how memo, useMemo and useCallback decide whether to skip work; it is how effect dependency arrays detect change; it is what makes a reducer testable and time-travel debugging possible. Mutating one object in one place quietly switches all of that off.

Next

Forms and Controlled Inputs — state, applied to every input on the page.