FullStack Course LogoFullStack Course
Module: React and Ecosystem
React and Ecosystem·094·11 MIN READ

094: State

TOPICS COVERED: State

Learning objective

Outcomes

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

By the end, you should be able to build task interactions driven by state without mutating data or treating a setter as though it were a normal assignment.

Prerequisites

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

Retrieval practice

  1. Why is an empty state different from a loading state?
  2. What is wrong with using a random key on every render?
  3. Which values should be derived instead of 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) gives the component the state value for the current render and a setter that requests another render. The useful distinction is that each render sees a snapshot. Calling the setter does not rewrite the variable inside code that is already running.

jsx
const [count, setCount] = useState(0);
  • Hook: A special function (one whose name begins with use) that adds state or other React capabilities to a component. — Source: React: Hooks reference
  • State variable: A value that useState persists between renders. — Source: React: useState
  • Setter: The function returned by useState; calling it schedules a re-render with a new value. — Source: React: useState
  • Snapshot: The state visible inside one render is fixed, so event handlers see the values from the render that created them. — Source: React: State as a snapshot
  • Updater function: The (prev) => next form, which queues an update that can be applied safely after earlier queued updates. — Source: React: Queueing state updates
  • Batching: Multiple state updates in one event are processed together and normally result in a single re-render. — Source: React: Queueing state updates
  • Immutability: Replacing an object or array rather than mutating it, allowing React and the rest of the application to detect the change reliably. — Source: React: Updating arrays in state

Call Hooks only at the top level of a component or custom Hook. Do not call one conditionally, inside a loop, or inside a nested event function. React relies on the order of Hook calls remaining stable from render to render.

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 calls written as setOpenCount(openCount + 1) all calculate from the same render snapshot. In the usual event-handling case, they therefore produce only one increment. Updater functions behave differently: React queues them, and each updater receives the result produced by the updater before it. Use an updater whenever the next state depends on the previous state.

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

If the event needs the next value immediately, calculate it explicitly, for example const next = openCount + 1. Do not read the state variable after calling the setter and expect that variable to have changed.

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 returns a new array, and { ...task } returns a new object for the task being changed. The other task objects keep their existing references. filter also returns a new array; it does not remove an item from the current array in place. Because openCount is calculated on every render, it cannot drift away from tasks.

Avoid:

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

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

Mutation changes the old snapshot and then passes React the same array reference. React may skip the update, while a previous render or another owner of the data can observe that supposedly old data changing underneath it.

Initial state

The initializer supplies the state value on the first render. If producing that value is expensive, pass a function rather than calling the expensive function during every render; React will call the initializer for initialization:

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

Pass the function itself, not readInitialTasks(). Initializers and updater functions must be pure. In development, Strict Mode may call them more than once to expose impure logic. Do not use an initializer to read and mutate local storage. A pure read and parse can be acceptable in a client-only application, but persistence synchronization belongs at the external-system boundary covered later.

Batching and state shape

React batches updates during an event and renders after processing them. That prevents the user from seeing a series of half-updated screens. Batching does not mean that different state values are automatically merged. An object state setter replaces the object, so copy any fields that should remain:

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

Keep state minimal. Do not store tasks, openTasks, and openCount as three separately maintained values. Store tasks and calculate the others. Likewise, avoid redundant flags when one status value describes the state more accurately.

Optional advanced: state identity

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

For complex transitions, useReducer can provide a central place to describe updates. For this small state model, useState is clearer. Do not introduce reducers, external stores, or memoization before the relationships between updates justify them.

Mistakes and debugging

  • Calling a Hook conditionally breaks React's Hook call order.
  • Mutating arrays or objects leads to stale or surprising screens.
  • Reading state immediately after a setter reads the old render snapshot.
  • Repeating setCount(count + 1) is incorrect when the updates depend on pending state.
  • Storing derived counts creates synchronization bugs.
  • Calling a handler in JSX, as in onClick={deleteTask(id)}, runs it during render.
  • Creating IDs during render changes list identity.
  • Object setters do not merge fields the way older class APIs did.

React DevTools can help you inspect the values associated with each render. For update logic, temporarily reduce the transition to a small pure expression and log the old and next references. When a container is supposed to change, old === next should be false. Keep Strict Mode enabled: duplicate development calls to an initializer usually reveal an impurity, not a reason to disable the checks.

Accessibility and performance

Every state-changing feature must be usable with a keyboard, so use actual buttons and properly labeled inputs. Repeated delete buttons need unique accessible names. Dynamic counts generally should not announce every keystroke; a restrained aria-live="polite" can help when the update is important and triggered by the user. Never communicate completion only with a strike-through or color. The checkbox supplies the state information.

Minimal state avoids extra renders and inconsistent copies of the same fact. Keep draft state close to its form so unrelated regions of the page do not need to render for every keystroke. Immutable updates make changes easier for React and developer tools to reason about. Do not add useMemo or useCallback without profiling; running 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, then derive the 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 calls 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, updater functions calculate safely from the pending previous state, and React batches updates from an event. Treat object and array state as read-only, create new 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 or object state update replace the container reference?
  3. Which state should be derived instead of stored?

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

State snapshots, batching, reducers, and derived data

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

Use a reducer when the transitions form a meaningful state machine, not simply 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 shape the state so impossible combinations are 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 handler that is currently executing.

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 gives bugs another place to hide. State should contain the minimum information needed to describe the UI.


Deep dive: state updates are queued work

Calling:

jsx
setCount(count + 1);

does not mutate count.

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

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 code does not mean “add 3.”

Use updater functions instead:

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

Each queued updater receives the result of the updater before it.

Batching

React batches many state updates, so multiple setters in one event can produce one commit instead of one commit per line.

That is why application code should not depend on the DOM updating immediately after each setter.

If code needs to react after state appears in the committed UI, redesign that work around state and Effects, or use a supported synchronous escape hatch only when it is genuinely necessary.

Object state

Wrong:

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

Problems:

  • mutates existing state;
  • may reuse the same object reference;
  • corrupts previous snapshots.

Correct:

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

Nested:

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

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

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 the current state array with push, splice, or in-place sort.

Lazy initial state

When initialization is expensive, use a function initializer:

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

React calls that initializer for the initial state rather than recalculating it 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 and updater functions more than once as a purity check.

Therefore:

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

is wrong because tracking is a side effect.

An initializer should calculate and return 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);

This is risky if the canonical task can be updated somewhere else.

Often better:

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

Reset behavior

State does not reset automatically when props change.

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

If a different task is selected while the Editor keeps the same identity, its old draft state may remain.

Options:

  1. use a key when selecting a new task should create a fresh editor;
  2. move ownership of the draft to the parent;
  3. intentionally synchronize the draft, but only when the requirements justify that behavior.

Do not add an Effect reflexively.

Functional updates prevent stale calculations

Updater functions are useful when:

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

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

State versus ref

Use state when changing a value should update the UI.

Use a ref when a mutable value must survive renders but changing it should not trigger a render.

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, local state is useful:

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

In production, TanStack Query can own that data and provide:

  • 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 does not show that React failed.

It shows the snapshot belonging to the current render.

If the computed next value is needed, calculate and log that value explicitly:

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

If the requirement is to observe synchronization after the UI commits, use the appropriate Effect or inspect the browser at that point.

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.
Reader page: /react/lesson/094/state