Module: React and Ecosystem
React and Ecosystem·094·9 MIN READ

094: State

TOPICS COVERED: State

Learning objective

Outcomes

You will use useState, explain state snapshots and batching, choose updater functions when next state depends on previous state, and update objects/arrays immutably.

I can build state-driven task interactions without mutating data or expecting setters to act like assignments.

Prerequisites

Complete 093. You should understand render-time branching, stable list identity, and JavaScript map, filter, and object spread.

Retrieval practice

  1. Why is empty different from loading?
  2. What is wrong with using a random key every render?
  3. Which values should be derived rather than synchronized?

Content to cover

useState; initial state; updater function; state-driven UI; batching concept.

Terms and mental model

State is a component's memory. useState(initialValue) returns the value for the current render and a setter that requests another render. Each render sees a snapshot: calling the setter does not alter the variable in already-running code.

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

Call Hooks only at the top level of a component or custom Hook, never in a condition, loop, or nested event function. React relies on stable call order.

Beginner complete example

jsx
import { useState } from 'react';

export default function TaskCounter() {
  const [openCount, setOpenCount] = useState(1);

  function addOne() {
    setOpenCount((count) => count + 1);
  }

  function addThree() {
    setOpenCount((count) => count + 1);
    setOpenCount((count) => count + 1);
    setOpenCount((count) => count + 1);
  }

  return (
    <main>
      <h1>Task capacity</h1>
      <p aria-live="polite">{openCount} open tasks</p>
      <button type="button" onClick={addOne}>Add one</button>
      <button type="button" onClick={addThree}>Add three</button>
      <button type="button" onClick={() => setOpenCount(0)}>Reset</button>
    </main>
  );
}

Three setOpenCount(openCount + 1) calls all calculate from the same snapshot and commonly produce only one increment. Updaters form a queue: each receives the result of the previous queued updater. Use an updater whenever next state depends on previous state.

jsx
console.log(openCount);
setOpenCount(openCount + 1);
console.log(openCount); // Same snapshot, not the future value.

Calculate const next = openCount + 1 if the event needs the next value immediately; do not read the variable after setting and expect it to change.

Intermediate: immutable task array

jsx
import { useState } from 'react';

const initialTasks = [
  { id: 't1', title: 'Practice state snapshots', completed: false },
  { id: 't2', title: 'Update arrays immutably', completed: true },
];

export default function App() {
  const [tasks, setTasks] = useState(initialTasks);

  function toggleTask(taskId) {
    setTasks((currentTasks) =>
      currentTasks.map((task) =>
        task.id === taskId ? { ...task, completed: !task.completed } : task,
      ),
    );
  }

  function deleteTask(taskId) {
    setTasks((currentTasks) =>
      currentTasks.filter((task) => task.id !== taskId),
    );
  }

  const openCount = tasks.filter((task) => !task.completed).length;

  return (
    <main>
      <h1>Task Manager</h1>
      <p>{openCount} open</p>
      {tasks.length === 0 ? <p>No tasks yet.</p> : (
        <ul>
          {tasks.map((task) => (
            <li key={task.id}>
              <label>
                <input
                  type="checkbox"
                  checked={task.completed}
                  onChange={() => toggleTask(task.id)}
                />
                {task.title}
              </label>
              <button type="button" onClick={() => deleteTask(task.id)}>
                Delete {task.title}
              </button>
            </li>
          ))}
        </ul>
      )}
    </main>
  );
}

map creates a next array; { ...task } creates the changed object. Unchanged records retain their references. filter creates a next array without deleting in place. openCount is derived every render, so it cannot drift from tasks.

Avoid:

js
tasks.push(newTask);
setTasks(tasks);

tasks[index].completed = true;
setTasks(tasks);

Mutation changes the old snapshot and passes the same array reference. React may skip an update, and prior renders or other owners observe altered data.

Initial state

The initializer is used on the first render. If creating initial state is expensive, pass a function so React calls it only for initialization:

jsx
const [tasks, setTasks] = useState(() => readInitialTasks());

Pass the function, not readInitialTasks(). Initializers and updater functions must be pure; Strict Mode may call them extra in development to expose impurities. Do not read and mutate local storage in an initializer; a pure read/parse can be acceptable in a client-only app, but persistence synchronization belongs at the external-system boundary covered later.

Batching and state shape

React batches updates during an event and renders afterward. Batching avoids half-updated screens. It does not mean multiple values are automatically merged. Object state setters replace the object, so copy retained fields:

jsx
const [draft, setDraft] = useState({ title: '', priority: 'normal' });
setDraft((current) => ({ ...current, priority: 'high' }));

Keep state minimal. Do not store tasks, openTasks, and openCount. Store tasks; calculate the others. Avoid redundant flags when one status value represents the state more accurately.

Optional advanced: state identity

State belongs to a component position in the render tree, not to the function declaration itself. Removing a component or changing its key resets its state. Rendering two <Counter /> nodes creates two independent state instances. Lifting state moves shared ownership to the common parent; it does not make state global.

For complex updates, useReducer may centralize transitions, but useState remains clearer for this small state model. Do not add reducers, external stores, or memoization before update relationships justify them.

Mistakes and debugging

  • Calling a Hook conditionally breaks call order.
  • Mutating arrays/objects produces stale or surprising screens.
  • Reading state immediately after a setter reads the old snapshot.
  • Using setCount(count + 1) repeatedly when updates depend on pending state.
  • Storing derived counts creates synchronization bugs.
  • Calling a handler in JSX, onClick={deleteTask(id)}, runs during render.
  • Creating IDs during render changes list identity.
  • Expecting object setters to merge fields like old class APIs.

Use React DevTools to inspect snapshots. Turn update logic into a small pure expression and log old and next references temporarily: old === next should be false when the container changes. Keep Strict Mode enabled; duplicate development initializer calls usually expose impurity rather than a reason to disable checks.

Accessibility and performance

Every state-changing feature must work with keyboard controls, so use buttons and labeled inputs. Give repeated delete buttons unique accessible names. Dynamic counts generally should not announce every keystroke; a restrained aria-live="polite" can help when the update is important and user-triggered. Never indicate completion only through strike-through or color; the checkbox conveys state.

Minimal state eliminates extra renders and inconsistency. Keep draft state near its form so unrelated page regions need not render on every keystroke. Immutable updates enable React and developer tools to reason about changes. Do not add useMemo/useCallback without profiling; filter over a learning-sized task list is inexpensive.

Practice

Build an interactive counter/cart/task state.

Tiered exercises

Core: Build +1, +3, and reset controls with updater functions.

Stretch: Toggle and delete task records immutably; derive open count.

Challenge: Add “complete all” and “clear completed” without mutation or duplicated state.

jsx
function completeAll() {
  setTasks((current) => current.map((task) => ({ ...task, completed: true })));
}

function clearCompleted() {
  setTasks((current) => current.filter((task) => !task.completed));
}

Complete counter:

jsx
import { useState } from 'react';
export default function Counter() {
  const [count, setCount] = useState(0);
  const increment = () => setCount((value) => value + 1);
  return (
    <main>
      <h1>Count: {count}</h1>
      <button type="button" onClick={increment}>+1</button>
      <button type="button" onClick={() => { increment(); increment(); increment(); }}>+3</button>
      <button type="button" onClick={() => setCount(0)}>Reset</button>
    </main>
  );
}

openCount = tasks.filter((task) => !task.completed).length remains derived. Neither challenge operation uses push, splice, property assignment, or in-place sorting.

Exit questions

  1. What problem does this concept solve?
  2. What is one common mistake?
  3. Can you explain the code without reading it line by line?

Recap

State is a per-render snapshot. Setters queue future renders, updaters safely calculate from pending previous state, and React batches event updates. Treat object and array state as read-only, create next values immutably, and derive everything possible during render.

Official references

Interview questions

  1. Why does setCount(count + 1) three times commonly increment once, while three updater calls increment three times?
  2. Why must an array/object state update replace the container reference?
  3. Which state should be derived rather than stored?

Strong answer: A handler sees one render snapshot; updater functions are queued against successive pending values. Create new containers immutably and store only facts such as tasks and filter, not counts or filtered copies.

State snapshots, batching, reducers, and derived data

Each render observes a state snapshot. A setter schedules a future render; it does not mutate the variable captured by the current handler. When the next value depends on the previous value, use an updater function.

Use a reducer when transitions form a meaningful state machine, not merely because an object has several fields. Keep derived values such as filtered tasks and counts out of state unless there is a measured reason to cache them. Test unknown reducer actions and make impossible states difficult to represent.


2026 depth expansion: state is a snapshot, not a mutable variable

Calling a setter schedules another render. It does not rewrite the state variable inside the currently executing handler.

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

  function handleClick() {
    setCount(count + 1);
    console.log(count); // still the snapshot for this render
  }

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

When the next value depends on the previous queued value, use the updater form:

jsx
setCount((current) => current + 1);

Three queued updates can then compose correctly:

jsx
setCount((n) => n + 1);
setCount((n) => n + 1);
setCount((n) => n + 1);

Do not store what you can derive

Avoid:

jsx
const [tasks, setTasks] = useState([]);
const [completedTasks, setCompletedTasks] = useState([]);

Prefer:

jsx
const completedTasks = tasks.filter((task) => task.completed);

Duplicated state creates synchronization work and bugs. State should contain the minimum information required to describe the UI.


Deep dive: state updates are queued work

Calling:

jsx
setCount(count + 1);

does not mutate count.

The current event handler still closes over the current render snapshot.

Example:

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

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

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

All three calls use the same snapshot value, so this does not mean "add 3."

Use updater functions:

jsx
setCount((n) => n + 1);
setCount((n) => n + 1);
setCount((n) => n + 1);

Each queued updater receives the result of the previous updater.

Batching

React batches many state updates so multiple setters within an event can produce one commit rather than one commit per line.

This means code should not depend on DOM updating immediately after each setter.

If you need to react after state appears in the committed UI, redesign around state/Effects or a supported synchronous escape hatch only when truly necessary.

Object state

Wrong:

jsx
profile.name = 'Asha';
setProfile(profile);

Problems:

  • mutates existing state;
  • same object reference may be reused;
  • previous snapshots become corrupted.

Correct:

jsx
setProfile((current) => ({
  ...current,
  name: 'Asha',
}));

Nested:

jsx
setProfile((current) => ({
  ...current,
  address: {
    ...current.address,
    city: 'Chennai',
  },
}));

For deeply nested domain structures, question whether the state shape itself should be normalized or split.

Array state

Add:

jsx
setTasks((current) => [
  ...current,
  newTask,
]);

Remove:

jsx
setTasks((current) =>
  current.filter((task) => task.id !== id),
);

Update:

jsx
setTasks((current) =>
  current.map((task) =>
    task.id === id
      ? { ...task, completed: true }
      : task,
  ),
);

Do not mutate with push, splice, or in-place sort on the current state array.

Lazy initial state

If initialization is expensive:

jsx
const [settings] = useState(() => {
  return loadInitialSettings();
});

React calls the initializer for initial state rather than on every render.

Do not use lazy initialization to perform unsafe side effects.

State initializer and Strict Mode

In development Strict Mode, React may call initializer/updater functions more than once to check purity.

Therefore:

jsx
useState(() => {
  analytics.track('initialized');
  return {};
});

is wrong because tracking is a side effect.

Initializer should calculate state only.

State shape design

Poor:

jsx
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');

fullName is derived.

Better:

jsx
const fullName = `${firstName} ${lastName}`.trim();

Poor:

jsx
const [selectedTask, setSelectedTask] = useState(task);

if the canonical task can be updated elsewhere.

Often better:

jsx
const [selectedId, setSelectedId] = useState(null);
const selectedTask =
  tasks.find((task) => task.id === selectedId) ?? null;

Reset behavior

State does not automatically reset when props change.

jsx
function Editor({ task }) {
  const [title, setTitle] = useState(task.title);
  ...
}

Selecting a different task may leave old draft state if Editor identity is preserved.

Options:

  1. use a key if a new task should mean a fresh editor;
  2. move draft ownership to parent;
  3. intentionally synchronize, but only when requirements justify it.

Do not add an Effect reflexively.

Functional updates prevent stale calculations

Useful when:

  • multiple updates are queued;
  • callback can execute later;
  • next state depends on previous state.
jsx
setItems((current) => current.filter(...));

This is generally safer than closing over items for state transitions.

State versus ref

Use state when changing the value should update UI.

Use a ref when a mutable value must survive renders but should not trigger one.

Example timer handle:

jsx
const timerRef = useRef(null);

Not:

jsx
const [timerId, setTimerId] = useState(null);

unless the timer ID itself is meaningful UI state.

State versus server cache

A response from /api/tasks is server state.

For a small learning example:

jsx
const [tasks, setTasks] = useState([]);

is useful.

In production, TanStack Query later owns that data so you gain:

  • freshness;
  • invalidation;
  • refetch;
  • caching;
  • retries;
  • deduplication;
  • mutation coordination.

Do not mirror query-cache data back into useState.

Debugging stale state

When a handler logs an "old value":

jsx
setCount(count + 1);
console.log(count);

the log is not proof React failed.

It is the current render snapshot.

If you need the computed next value:

jsx
const next = count + 1;
setCount(next);
console.log(next);

If you need to observe committed UI synchronization, use the appropriate Effect or browser inspection.

Exercises

  1. Demonstrate direct setter versus updater-function queuing.
  2. Refactor nested mutation into immutable updates.
  3. Remove duplicated derived state.
  4. Build an editor and intentionally reset it with key.
  5. Compare state and ref for a timer handle.
  6. Explain why API response caching eventually belongs outside plain local state.

Mastery check

Explain:

  • snapshot semantics;
  • batching;
  • updater functions;
  • immutable object/array updates;
  • lazy initializers;
  • why derived values should usually not be stored;
  • how state identity relates to keys.