State in Depth: useState, Updates, and Batching

Build a thorough working model of React state — the useState hook, initial and lazy initialization, functional updates, batching, immutability, derived vs. redundant state, lifting and colocating state, and how state is preserved or reset across renders.

State is what makes a React component more than a static rendering function — it's the data a component remembers between renders and can update in response to events. Nearly every non-trivial bug in a React app traces back to a misunderstanding of exactly one of the topics in this lesson: when state updates apply, whether they're batched, why state sometimes resets unexpectedly, or where a piece of state should actually live. This lesson builds the complete mental model, piece by piece.

The state concept and useState

State is a value a component owns that can change over time and, when it changes, causes React to re-render that component (and its children) with the new value. Unlike a plain local variable inside the function body — which is reset to its initial value on every render — state persists across renders because React stores it outside the function, associated with that specific component instance.

useState is the hook that opts a value into this behavior. const [count, setCount] = useState(0) declares a state variable count starting at 0, plus a setter function setCount that's the *only* correct way to update it. Calling setCount(5) does two things: it tells React to remember 5 as the new value for the next render, and it schedules a re-render of the component so the UI reflects that new value.

A critical mental model shift for beginners: calling the setter does not change count immediately, in place, the way mutating a variable would. The count you're holding in the current render's closure stays whatever it was when that render started; the *new* value only becomes visible starting with the next render, where useState returns it fresh. This is why console.log(count) immediately after setCount(count + 1) prints the *old* value — the update hasn't taken effect yet in the current render's closure.

Initial state and lazy initialization

useState(0) and useState(expensiveComputation()) look similar but behave very differently. React only ever *uses* the initial value on the component's very first render — but a plain expression passed as the argument (like expensiveComputation()) is still *evaluated* on every single render, even though the result is thrown away after the first one. For a cheap value like 0 or "", this waste is invisible; for an expensive computation (parsing a large JSON string, reading from localStorage, building a large initial array), it's a real, repeated cost.

Lazy initialization solves this: pass a *function* instead of a value — useState(() => expensiveComputation()). React calls this function only once, on the first render, and ignores it on every subsequent render, since it already has the state stored. The function you pass takes no arguments and must return the desired initial value.

The rule of thumb: if computing the initial value is a one-line literal or a cheap expression, pass it directly. If it involves any real work — reading from storage, parsing, filtering a large array, calling a function with meaningful cost — wrap it in a lazy initializer function.

Functional updates and batching

When a new state value depends on the previous value, pass a function to the setter instead of a plain value: setCount((prevCount) => prevCount + 1) instead of setCount(count + 1). React calls this updater function with the most up-to-date pending state — which matters enormously when multiple updates to the same state are queued before a re-render happens. setCount(count + 1); setCount(count + 1); inside one event handler only increments by 1 total, because both calls close over the same stale count. setCount((c) => c + 1); setCount((c) => c + 1); correctly increments by 2, because each updater function receives the result of the previous one in the queue.

Batching is the mechanism that makes this queuing necessary in the first place: React groups multiple state updates that happen within the same event handler (or, since React 18, within timeouts, promises, and other async callbacks too — 'automatic batching') into a single re-render instead of re-rendering after every individual setState call. This is both a performance optimization and the reason functional updates matter — since several updates to the same piece of state can be queued up before any of them are actually applied against a fresh render.

Automatic batching (React 18+) extended this grouping to contexts that previously re-rendered per update — a setTimeout callback, a fetch().then() callback, a native event listener. Before React 18, only updates inside React's own event handlers were batched; now nearly everywhere is, which is one more reason to always reach for the functional updater form whenever a new value depends on the current one, regardless of where the update is triggered from.

Immutability, derived state, and avoiding redundant state

React detects whether state has changed by comparing the old and new values with Object.is (roughly ===), not by deeply inspecting an object's contents. This means mutating an object or array in place — user.name = "New Name"; setUser(user); — passes the *same reference* back to setUser, so React sees no change and skips the re-render, even though the object's contents did change. The fix is always to create a new object or array: setUser({ ...user, name: "New Name" }), setItems([...items, newItem]), setItems(items.filter((i) => i.id !== idToRemove)).

Derived state is a value that can always be computed from existing state or props during render, and it should *not* be stored in its own useState. If you already have items: Item[] in state, const total = items.reduce((sum, i) => sum + i.price, 0) is derived — compute it directly in the render body, every render, rather than storing total in a second state variable and trying to keep the two in sync with an effect.

This is the single most common state anti-pattern: redundant state. Storing total as its own useState means every place that changes items must also remember to update total, and any place that forgets creates a UI that silently shows a stale, wrong total. The fix is always the same — delete the redundant state variable and compute the value fresh in the render body. A value only deserves its own useState when it cannot be computed from something else the component already has.

• Always update objects/arrays by creating a new copy (spread, filter, map), never by mutating in place. • If a value can be computed from existing state/props during render, compute it — don't store it separately. • Redundant state is the most common source of 'my UI shows a stale value' bugs.

Lifting state, colocation, and state preservation/reset

Lifting state up means moving a piece of state from a child component to the nearest common ancestor of every component that needs to read or change it. If two sibling components both need to reflect the same value (a selected tab, a filter, a search query), that value can't live in either sibling's own state — it has to live in their shared parent, which then passes the value down as a prop and passes an updater function down as another prop, so both siblings stay in sync.

Colocation is the opposite pull: keep state as close as possible to where it's actually used, and lift it only as far up as necessary — never straight to the top of the app 'just in case.' A form field's typing-in-progress value, for instance, almost always belongs in that field's own component, not in a distant ancestor, because nothing else in the tree needs to read it. Over-lifting state causes unrelated parts of the tree to re-render on every keystroke and makes the data flow harder to trace; under-lifting causes siblings to fall out of sync. The right home for a piece of state is the lowest common ancestor of everything that actually needs it — no higher, no lower.

React preserves a component's state across re-renders as long as the same component type renders in the same position in the tree; it resets that state (unmounting the old instance, mounting a fresh one) when the component's type changes at that position, or when its key prop changes. This is why swapping which component renders at a given spot — {isEditing ? <EditForm /> : <ViewMode />} — resets any state the previous one held, while conditionally rendering the *same* component with different props preserves its state. Passing an explicit, changing key (<UserForm key={userId} />) is the standard way to force a full state reset intentionally whenever the identity of what's being edited changes, even though the component type stays the same.

useState, lazy init, functional updates, and the module's reference example

useState<Item[]>(() => ...) combines an explicit generic type with a lazy initializer function. Changing key from one selectedUserId to another remounts UserForm, clearing any leftover state from editing the previous user.

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

// Lazy initialization — the function runs only on the first render
const [items, setItems] = useState<Item[]>(() => loadItemsFromStorage());

// Functional update — always safe when the new value depends on the old one
function increment() {
  setCount((prevCount) => prevCount + 1);
}

// Immutable update to an array in state
function addItem(newItem: Item) {
  setItems((prevItems) => [...prevItems, newItem]);
}

// Derived value — computed every render, never stored in its own state
const total = items.reduce((sum, item) => sum + item.price, 0);

// Forcing a state reset when the identity of the edited entity changes
<UserForm key={selectedUserId} userId={selectedUserId} />;

Code Examples

count is state that starts at 0 and is remembered across re-renders; clicking the button calls setCount, which schedules a re-render with the new value. Expected result: Displays 'Count: 0' initially; each click increases the displayed number by one.

In the broken version, all three setCount calls close over the same stale count from this render, so each one schedules the identical update — the net effect is +1, not +3. The fixed version uses functional updates, so each updater receives the result of the previous queued update, correctly compounding to +3.

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

Common mistake: Calling setCount(count + 1) multiple times in the same handler when the new value depends on the current value is the single most common useState bug.

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}
function TripleIncrementBroken() {
  const [count, setCount] = useState(0);
  function handleClick() {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  }
  return <button onClick={handleClick}>Count: {count}</button>; // increments by only 1
}

function TripleIncrementFixed() {
  const [count, setCount] = useState(0);
  function handleClick() {
    setCount((c) => c + 1);
    setCount((c) => c + 1);
    setCount((c) => c + 1);
  }
  return <button onClick={handleClick}>Count: {count}</button>; // increments by 3
}

Where a piece of state should live

State lives at the lowest common ancestor of everything that needs to read or change it — no higher, no lower.

FilterableList (state: selectedFilter)
  |
  +--> FilterTabs (reads selectedFilter, calls onSelect prop)
  |
  +--> ItemList (reads selectedFilter as a prop, filters items)

(selectedFilter is lifted here because BOTH children need it —
 it is not colocated inside either child alone.)

Important Rules

Never mutate state directly; always call the setter with a new object/array (spread, map, filter) so React can detect the change.

Use a functional updater (setCount(c => c + 1)) whenever the new state depends on the previous state, especially with multiple updates in one handler.

Wrap an expensive initial-state computation in a function (useState(() => ...)) so it only runs once, not on every render.

Never store a value in its own useState if it can be computed from existing state/props during render — that's derived state, and computing it directly avoids sync bugs.

Lift state only as high as the lowest common ancestor of every component that needs it; keep everything else colocated where it's used.

Change a component's key to intentionally force React to reset its state by unmounting and remounting a fresh instance.

Where this shows up

A shopping cart's item count and subtotal are classic derived state: storing the count and total as separate useState variables alongside the cart items array is a common source of production bugs where the displayed total silently disagrees with what's actually in the cart after certain actions. Computing both directly from the items array on every render eliminates that entire class of bug permanently.

user.name = newName; setUser(user);

setUser({ ...user, name: newName });

Mutating the existing object keeps the same reference, so React's Object.is comparison sees 'no change' and may skip re-rendering even though the data changed.

const [total, setTotal] = useState(0); //...then an effect to keep total in sync with items const total = items.reduce((sum, i) => sum + i.price, 0); // computed every render

Storing a derivable value as its own state creates a second source of truth that can drift out of sync with items whenever an update path forgets to also update total.

setCount(count + 1); setCount(count + 1); // intending +2 setCount((c) => c + 1); setCount((c) => c + 1); // correctly +2

Both calls in the wrong version read the same stale count captured in this render's closure, so the second call overwrites the first instead of compounding.

[multiple-choice] Why does setCount(count + 1); setCount(count + 1); in the same handler only increase count by 1, not 2? A. React ignores the second call entirely B. Both calls read the same stale count value captured in the current render's closure C. useState only allows one update per component lifetime D. It's a syntax error

B. Both calls read the same stale count value captured in the current render's closure — Both calls close over the same count from the render that's currently executing, so both compute and schedule the identical new value; functional updates (c => c + 1) are needed to correctly compound multiple updates.

[multiple-choice] When should a value be stored in its own useState rather than computed during render? A. Always, for consistency B. Only when it cannot be derived from existing state or props C. Only for numbers, never for strings D. Never — all state should be derived

B. Only when it cannot be derived from existing state or props — A value that can be computed from state or props already in the component should be computed directly during render; giving it its own state creates a second source of truth that can drift out of sync (redundant state).

[true-false] True or False: Changing a component's key prop causes React to reset its state by unmounting the old instance and mounting a new one. A. True B. False

A. True — React uses type and key together to determine component identity across renders; a changed key at the same position tells React this is a conceptually different instance, so it discards the old state and mounts fresh.

A component tracks a list of todo items in state and separately tracks 'completedCount' in its own useState, updated manually every time a todo is toggled. Identify why this is risky and rewrite it to remove the redundant state.

// Before: const [completedCount, setCompletedCount] = useState(0); — updated manually, can drift out of sync. // After: const [todos, setTodos] = useState<Todo[]>([]); const completedCount = todos.filter((t) => t.completed).length; // derived, always correct completedCount can always be computed from the todos array itself.