Ch. 3 · React

React Functional Updates and Pure State Changes

Why React updater functions like setItems(prev => ...) must be pure, why Strict Mode calls them twice, and how mutation causes duplicates.

~6 min readbeginnerupdated Oct 6, 2026

“Why does my item get added twice in development but only once in production?” Interviewers like this question because it sounds like a React bug, and the candidate who can explain that it is their own impure updater function, exposed on purpose by Strict Mode, clearly understands the contract React expects from state updates.

A close cousin of the question is “when should you use setX(prev => ...) instead of setX(value), and what are the rules for that function?” Knowing when to use the functional form is half the answer. Knowing what the function is allowed to do is the half most candidates miss.

Before you start

You should know how useState works and that setting state queues a re-render rather than changing the variable in place (the state snapshot guide covers this). You should also know the difference between mutating an array with push and creating a new one with spread syntax. The examples run in React 18 or 19; Strict Mode double invocation exists in both.

The short answer

A functional update, setState(prev => next), hands React a function to run while it processes the state queue. React treats that function like a reducer: it must compute the next state only from its argument and must not mutate the argument or cause side effects. In development under Strict Mode, React calls it twice and keeps one result, so a mutation or a network call inside it shows up as duplicated data. Keep updaters as pure calculations and put side effects in the event handler.

How it works

React does not call your updater at the moment you call the setter. It stores the function in the hook’s queue and runs it during the next render, when it computes the new state. Because React may run that computation more than once (Strict Mode in development, or when concurrent rendering discards and restarts work), the function must give the same answer for the same input and leave the input untouched.

Here is an impure updater and what double invocation does to it:

const prev = { items: ['a'] };
const impure = p => { p.items.push('b'); return { ...p }; };

impure(prev);
const result = impure(prev);
console.log(result.items); // [ 'a', 'b', 'b' ]
JavaScript

The first call mutated prev.items, so the second call started from already-changed data. React discards one result, but the mutation already happened on the shared array, so the duplicate survives. The pure version is harmless to repeat:

const prev2 = { items: ['a'] };
const pure = p => ({ ...p, items: [...p.items, 'b'] });

pure(prev2);
const r = pure(prev2);
console.log(r.items, prev2.items); // [ 'a', 'b' ] [ 'a' ]
JavaScript

There is a second reason purity matters. React compares the returned state with Object.is. If you mutate the previous array and return the same reference, React may decide nothing changed and skip the re-render entirely, which looks like “state did not update”.

Step-by-step walkthrough

Step 1: Decide whether you need the functional form

Use an updater when the next state depends on the previous state and more than one update might be queued before the next render: counters, toggles, appending to a list, or updates from intervals and subscriptions whose closures are stale.

setOpen(o => !o);              // depends on previous
setItems(list => [...list, x]); // depends on previous
setName(event.target.value);    // does not; plain value is clearer
JSX

Step 2: Write the updater as a pure calculation

Read only the argument and values that are constant for this event (such as x above). Return new objects and arrays rather than editing existing ones:

setCart(cart => ({
  ...cart,
  lines: cart.lines.map(line =>
    line.id === id ? { ...line, qty: line.qty + 1 } : line
  ),
}));
JSX

Every level that changes gets a fresh copy; levels that do not change keep their reference, which helps memoised children skip work.

Step 3: Move side effects out

Anything that talks to the outside world, such as fetch, localStorage, analytics, a toast, or another component’s setter, does not belong inside the updater. Compute what you need in the handler instead:

function handleAdd(product) {
  setCart(cart => addLine(cart, product)); // pure
  track('add_to_cart', { id: product.id }); // side effect, runs once
}
JSX

If the side effect needs the resulting state, derive it in render or in an effect that reacts to the state change, rather than smuggling it into the updater.

Step 4: Keep Strict Mode on and treat duplicates as a signal

Strict Mode double-calling is not something to switch off. It is a cheap test that your updaters, reducers and component bodies are pure. If a value doubles only in development, look for mutation or side effects in one of those three places first. The Strict Mode double invoke guide lists everything React calls twice.

Worked scenario

A notifications panel keeps a list and an unread counter. The original code:

function onMessage(msg) {
  setNotifications(list => {
    list.unshift(msg);                   // mutation
    setUnread(n => n + 1);               // setter inside updater
    navigator.vibrate?.(50);             // side effect
    return list;
  });
}
JSX

In development every message appears twice, the unread count jumps by two, and the device vibrates twice. In production the list sometimes fails to re-render, because list is returned by reference after mutation.

The fix splits calculation from effects:

function onMessage(msg) {
  setNotifications(list => [msg, ...list]);
  setUnread(n => n + 1);
  navigator.vibrate?.(50);
}
JSX

Now the updaters are pure, both setters are called once from the handler, and the vibration happens exactly once. Better still, if unread can be computed from the list (say, from a read flag), remove that state entirely and derive it during render.

Common mistake

  • “React is buggy, it runs my code twice.” Only in development under Strict Mode, and only to reveal impurity. Production runs it once, but concurrent rendering can still re-run work, so impure code is fragile there too.
  • Calling another setter inside an updater. It looks harmless but it is a side effect that will be duplicated, and it can trigger the “Cannot update a component while rendering” warning if the setter belongs to another component.
  • Returning the mutated previous value. list.push(x); return list; returns the same reference, so React can bail out of the render.
  • Thinking the functional form is “faster”. It is about correctness when updates queue up, not performance.

Verify the behavior

Wrap a component in <StrictMode> and log inside the updater:

import { StrictMode, useState } from 'react';

function Adder() {
  const [items, setItems] = useState([]);
  return (
    <button onClick={() =>
      setItems(prev => {
        console.log('updater ran with length', prev.length);
        return [...prev, prev.length];
      })
    }>
      {items.join(',') || 'empty'}
    </button>
  );
}

export default function App() {
  return <StrictMode><Adder /></StrictMode>;
}
JSX

One click logs updater ran with length 0 twice (with React DevTools installed, the second log may appear dimmed), yet the button shows 0 once. Change the body to prev.push(prev.length); return [...prev]; and the button shows 0,0, the exact duplicate bug from the scenario.

Follow-up questions

Are reducers held to the same rule? Yes. A useReducer reducer is the same idea with named actions, and React double-calls it in Strict Mode for the same reason. See reducer invariants.

Can an updater read props? It can read values that are constant for the event, such as props captured by the handler. Those are fixed inputs, so purity holds.

Is Math.random() or Date.now() allowed in an updater? It breaks purity because two calls give different answers. Generate the id or timestamp in the handler and pass it in.

Does useState(initializer) follow the same rule? Yes. Initializer functions are also called twice in Strict Mode and must be pure.

Interview exercise

A teammate writes setTags(t => { t.add(newTag); return t; }) where tags is a Set. The UI does not update. Explain why and fix it.

Answer and reasoning

t.add mutates the existing Set and the updater returns the same reference. React compares old and new state with Object.is, sees the same object, and bails out of the re-render. Even when something else causes a render, the mutation is invisible to memoised children. The fix is to return a new set: setTags(t => new Set(t).add(newTag)). Set.prototype.add returns the set, so this is a pure, single-expression updater that is safe to call twice.

Continue learning

Keep practising with the React interview questions and the React MCQs. Related notes: state snapshots, Strict Mode double invoke and reducer invariants. Official reference: the useState updater function documentation and Queueing a Series of State Updates on react.dev.

More in React

read ✓React · easy

React Refs Versus State for Mutable Values

useRef vs useState in React: refs change without re-rendering, state drives the UI. Learn which values belong where, with timer and DOM examples.

~6 min readread →
esc