Topic #15Core16 min read

State (useState)

State from first principles: what useState is and how it works, the render snapshot model, immutable updates for arrays and objects, functional updaters and batching, lazy initialization, why state persists across renders, lifting state up, and the pitfalls that produce stale UI.

#react#state#usestate#immutability#batching#functional-updater#lifting-state#snapshot

What state is. State is data that (a) a component owns, (b) can change over time, and (c) triggers a re-render when it changes. Unlike props (passed in, read-only), state is local memory that survives between renders. If a value is derived purely from props or other state, it usually shouldn't be state at all — compute it during render instead.

Declaring state with useState. You create a state variable with the useState Hook, which returns a pair: the current value and a setter function — const [amount, setAmount] = useState(0). The argument is the initial value, used only on the first render. Calling the setter (setAmount(5)) schedules a re-render in which amount will be the new value.

State as a per-render snapshot (the key insight). Within a single render, a state variable is a constant snapshot — it does not change mid-function even after you call the setter. setAmount(amount + 1) doesn't mutate amount; it requests a new render where amount is the next value. This is why logging amount right after setAmount shows the old value: you're still in the render that captured the old snapshot.

Never mutate state directly. The first golden rule: always update through the setter with a new value; never assign to or mutate the existing state (items.push(...), user.name = 'x'). React detects changes by comparing references, so mutating in place leaves the reference unchanged, React skips the re-render, and the UI goes stale. Treat state as immutable.

Immutable updates for objects and arrays. To 'change' an object or array in state, build a new one. Objects: spread and override — setUser(u => ({ ...u, name: 'Ada' })). Arrays: add with [...prev, item], remove with prev.filter(...), update with prev.map(...). Never use in-place mutators like push, splice, sort, or direct index assignment on state arrays.

Functional updaters. The second golden rule: when the next value depends on the previous, pass a function to the setter — setCount(prev => prev + 1) — instead of setCount(count + 1). The updater receives the latest queued value, so it's correct even when React batches multiple updates or the closed-over variable is stale. It's essential when several updates happen in one event.

Batching. React batches multiple state updates that occur in the same event (and, since React 18, in promises, timeouts, and native handlers too) and re-renders once with all of them applied. So three setX calls in one click produce one render, not three. This is why setCount(count+1) called twice only increments by one — both read the same stale snapshot — while two functional updaters increment by two.

Lazy initialization. If computing the initial state is expensive, pass a function instead of a value: useState(() => expensiveInit()). React calls it only on the first render, skipping the cost on every subsequent render. Passing useState(expensiveInit()) (calling it) would run the computation every render even though the result is ignored after the first.

State persists because of stable position. React keeps state associated with a component by its position in the tree, not by variable name. As long as the same component renders in the same spot, its state survives across re-renders. Remove it from the tree (or change its type/key) and the state is discarded. This is also why keys matter for lists and why conditionally rendering different components at the same position resets state.

When to reach for useReducer. useState is ideal for simple, independent values. When state is complex — multiple sub-values that change together, or updates that follow clear 'actions' — useReducer centralizes the transition logic into one pure reducer function, making updates predictable and testable. It's the same immutability rules, just organized differently.

Lifting state up. When two sibling components need to share or stay in sync with the same data, move that state to their closest common parent and pass it down as props (with callbacks to update it). This 'single source of truth' pattern is the standard React answer to 'how do two components share state' — you lift it up rather than duplicating it.

The mental model (memorise this). State is owned, changeable, render-triggering memory declared with const [x, setX] = useState(init). Within a render, x is a frozen snapshot; calling setX requests a new render, it doesn't mutate x. Never mutate — always set a new value (spread objects/arrays). Use prev => ... when the next value depends on the previous, remember updates are batched, and lift shared state to the nearest common parent.

Backend Analogy

State is like a request-scoped or session-scoped field the framework manages for you across invocations — but with a strict rule: you never mutate it in place, you replace it, much like working with immutable value objects and returning a new instance instead of setter-mutating. The functional updater setX(prev => next) is a compare-and-set / atomic accumulate: it reads the freshest value under the hood, so concurrent-ish batched updates compose correctly (contrast setX(x+1) twice, which is a lost update because both read a stale snapshot). Batching is transactional write coalescing — several changes flushed together in one commit rather than one render per write. The render snapshot is like each handler invocation seeing an immutable copy of the model; you don't see your own write until the next 'request' (render). Lifting state up is hoisting shared mutable state into a single owner to avoid two caches drifting out of sync.

Key Insights
  • State is owned, changes over time, and triggers a re-render; derived values should be computed in render, not stored as state.
  • useState(initial) returns [value, setter]; initial is used only on the first render.
  • Within one render a state variable is a frozen snapshot — calling the setter schedules a new render, it doesn't mutate the current value.
  • Never mutate state in place; React compares references, so mutation leaves the UI stale.
  • Update objects/arrays immutably: spread objects, and use map/filter/[...prev] for arrays (never push/splice/sort in place).
  • Use the functional updater setX(prev => ...) when the next value depends on the previous.
  • React batches multiple updates in an event into one re-render (extended to async in React 18).
  • Use lazy init useState(() => compute()) to run an expensive initializer only once.
  • State is tied to a component's position in the tree; removing it or changing key/type discards the state.
  • For complex/related state use useReducer, and lift shared state to the nearest common parent (single source of truth).

Worked Code

Declaring state and immutable array/object updates
TSX
import { useState } from 'react';

type Expense = { id: number; amount: number; approved: boolean };

function ExpenseForm() {
  const [amount, setAmount] = useState<number>(0);        // primitive
  const [items, setItems] = useState<Expense[]>([]);      // array
  const [user, setUser] = useState({ name: '', vip: false }); // object

  const add = () => {
    const next: Expense = { id: Date.now(), amount, approved: false };
    // ✅ arrays: build a NEW array
    setItems(prev => [...prev, next]);
    setAmount(0);
  };

  const approve = (id: number) =>
    // ✅ update one item immutably with map
    setItems(prev => prev.map(e => e.id === id ? { ...e, approved: true } : e));

  const remove = (id: number) =>
    // ✅ remove with filter
    setItems(prev => prev.filter(e => e.id !== id));

  const rename = (name: string) =>
    // ✅ objects: spread then override
    setUser(prev => ({ ...prev, name }));

  return null; // (UI omitted)
}
The snapshot trap: stale value and functional updaters
TSX
import { useState } from 'react';

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

  const brokenDoubleIncrement = () => {
    // ❌ both read the SAME stale snapshot (count = 0) -> ends at 1, not 2
    setCount(count + 1);
    setCount(count + 1);
    console.log(count); // logs the OLD value (0) — still this render's snapshot
  };

  const correctDoubleIncrement = () => {
    // ✅ functional updater reads the latest queued value each time -> +2
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
  };

  return <button onClick={correctDoubleIncrement}>{count}</button>;
}
Lazy initialization (run the initializer only once)
TSX
import { useState } from 'react';

function readInitialTodos() {
  // pretend this is expensive: parse localStorage, decode, etc.
  return JSON.parse(localStorage.getItem('todos') ?? '[]');
}

function TodoApp() {
  // ✅ pass a FUNCTION: React calls it only on the first render.
  const [todos, setTodos] = useState(() => readInitialTodos());

  // ❌ useState(readInitialTodos()) would run it on EVERY render
  //    (result ignored after the first) — wasteful.
  return <p>{todos.length} todos</p>;
}
Lifting state up so siblings share one source of truth
TSX
import { useState } from 'react';

// Parent OWNS the shared state and passes it to both children.
function Filters() {
  const [query, setQuery] = useState('');
  return (
    <>
      <SearchBox query={query} onChange={setQuery} />
      <Results query={query} /> {/* stays in sync automatically */}
    </>
  );
}

function SearchBox({ query, onChange }: { query: string; onChange: (v: string) => void }) {
  return <input value={query} onChange={e => onChange(e.target.value)} />;
}
function Results({ query }: { query: string }) {
  return <p>Searching for: {query}</p>;
}

Try It Live

Edit the code and press Run — it executes safely in a sandboxed iframe. Use the Console tab for log output.

Immutable vs mutating updates — why the setter needs a NEW reference

Interview-Ready Q&A

React detects changes by comparing references with Object.is. If you mutate the existing array or object in place (items.push(...)), the reference is unchanged, so React may skip the re-render and the UI goes stale. You must call the setter with a new value (a new array/object), which both triggers a re-render and keeps updates predictable. Treat state as immutable.

Things to Remember
  • 1State is owned, changeable, render-triggering memory: const [x, setX] = useState(initial).
  • 2Within a render, state is a frozen snapshot; setX schedules a new render, it doesn't mutate x.
  • 3Never mutate state in place — React compares references and will skip the update.
  • 4Update objects with spread, arrays with map/filter/[...prev] (never push/splice/sort).
  • 5Use setX(prev => ...) when the next value depends on the previous.
  • 6Updates are batched into one re-render (async too, in React 18).
  • 7Use lazy init useState(() => compute()) for expensive initial values.
  • 8State is tied to the component's position/key in the tree; remove or re-key it and it resets.
  • 9Prefer useReducer for complex or action-driven state.
  • 10Lift shared state to the nearest common parent as the single source of truth.

References & Further Reading