094: 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
- Why is empty different from loading?
- What is wrong with using a random key every render?
- 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.
const [count, setCount] = useState(0);
- Hook: Special function (use*) adding state/effects capabilities to components. — Source: React: Hooks reference
- State variable: Value persisted between renders by useState. — Source: React: useState
- Setter: Function returned by useState scheduling a re-render with a new value. — Source: React: useState
- Snapshot: State inside one render is fixed — handlers see the values of their render. — Source: React: State as a snapshot
- Updater function: (prev) => next form queueing safe sequential updates. — Source: React: Queueing state updates
- Batching: Multiple setState calls in one event merge into a single re-render. — Source: React: Queueing state updates
- Immutability: Replacing objects/arrays instead of mutating so React detects change. — Source: React: Updating arrays in state
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
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.
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
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:
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:
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:
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.
function completeAll() {
setTasks((current) => current.map((task) => ({ ...task, completed: true })));
}
function clearCompleted() {
setTasks((current) => current.filter((task) => !task.completed));
}
Complete counter:
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
- What problem does this concept solve?
- What is one common mistake?
- 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
- React: State as a Snapshot
- React: Queueing a Series of State Updates
- React: Updating Arrays in State
- React:
useState
Interview questions
- Why does
setCount(count + 1)three times commonly increment once, while three updater calls increment three times? - Why must an array/object state update replace the container reference?
- 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.
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:
setCount((current) => current + 1);
Three queued updates can then compose correctly:
setCount((n) => n + 1);
setCount((n) => n + 1);
setCount((n) => n + 1);
Do not store what you can derive
Avoid:
const [tasks, setTasks] = useState([]);
const [completedTasks, setCompletedTasks] = useState([]);
Prefer:
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:
setCount(count + 1);
does not mutate count.
The current event handler still closes over the current render snapshot.
Example:
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:
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:
profile.name = 'Asha';
setProfile(profile);
Problems:
- mutates existing state;
- same object reference may be reused;
- previous snapshots become corrupted.
Correct:
setProfile((current) => ({
...current,
name: 'Asha',
}));
Nested:
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:
setTasks((current) => [
...current,
newTask,
]);
Remove:
setTasks((current) =>
current.filter((task) => task.id !== id),
);
Update:
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:
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:
useState(() => {
analytics.track('initialized');
return {};
});
is wrong because tracking is a side effect.
Initializer should calculate state only.
State shape design
Poor:
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
fullName is derived.
Better:
const fullName = `${firstName} ${lastName}`.trim();
Poor:
const [selectedTask, setSelectedTask] = useState(task);
if the canonical task can be updated elsewhere.
Often better:
const [selectedId, setSelectedId] = useState(null);
const selectedTask =
tasks.find((task) => task.id === selectedId) ?? null;
Reset behavior
State does not automatically reset when props change.
function Editor({ task }) {
const [title, setTitle] = useState(task.title);
...
}
Selecting a different task may leave old draft state if Editor identity is preserved.
Options:
- use a key if a new task should mean a fresh editor;
- move draft ownership to parent;
- 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.
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:
const timerRef = useRef(null);
Not:
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:
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":
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:
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
- Demonstrate direct setter versus updater-function queuing.
- Refactor nested mutation into immutable updates.
- Remove duplicated derived state.
- Build an editor and intentionally reset it with
key. - Compare state and ref for a timer handle.
- 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.
