React State from first principles
Learn react useState hook from first principles. Use states with confidence.
React State & useState() — Deep Dive Reference
A companion reference doc for a tutorial video on React state, focused on useState(). Assumes basic familiarity with React/JSX.
Table of Contents
- Foundations — What “State” Actually Is
useStateMechanics- The Async / Batched Nature of Updates
- Immutability Rules
- Common Gotchas
- State Architecture & Design Decisions
useStatevs. Everything Else- 2026 Context: React Compiler & Newer Hooks
- Performance Considerations
- Hands-on Demos
- Cheat Sheet
1. Foundations — What “State” Actually Is
State vs. a plain variable. A regular JS variable can change, but changing it doesn’t tell React to re-render. State is the mechanism that does.
// ❌ A plain variable — the value changes, but the UI never updates
function BrokenCounter() {
let count = 0;
const increment = () => {
count++;
console.log(count); // this logs correctly...
};
return <button onClick={increment}>{count}</button>; // ...but this never re-renders
}
// ✅ State — React knows to re-render when this changes
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
State vs. props vs. refs (quick distinctions):
| Owned by | Triggers re-render on change? | Persists across renders? | |
|---|---|---|---|
| State | The component itself | Yes | Yes |
| Props | Passed down from parent | Yes (when parent re-renders) | Yes |
| Refs | The component itself | No | Yes |
| Plain variable | Local to a render | No (and resets every render) | No |
The render cycle — every state update walks through three phases:
- Trigger —
setStateis called (by an event, effect, etc.) - Render — React calls your component function again to figure out what the UI should look like
- Commit — React applies the minimal set of DOM changes needed
Understanding this cycle explains most “why did/didn’t this re-render” questions later in the doc.
2. useState Mechanics
Basic syntax:
import { useState } from "react";
function Example() {
const [value, setValue] = useState(initialValue);
// value -> current state, only changes on next render
// setValue -> function that schedules an update
// initialValue -> used ONLY on the very first render
}
Initial value is only used once. A common trap:
function Timer() {
// Date.now() runs once, on mount — it will NOT update on re-renders
const [startTime] = useState(Date.now());
return <p>Started at: {startTime}</p>;
}
Lazy initialization — pass a function instead of a value when computing the initial state is expensive. React only calls it on the first render.
function computeExpensiveInitialList() {
console.log("This should only run once!");
return Array.from({ length: 10000 }, (_, i) => i * 2);
}
// ❌ Runs computeExpensiveInitialList() on EVERY render, then discards the result
const [items, setItems] = useState(computeExpensiveInitialList());
// ✅ React only invokes this function once, on mount
const [items, setItems] = useState(() => computeExpensiveInitialList());
Direct update vs. functional update:
// Direct update — fine when the new value doesn't depend on the previous one
setCount(5);
// Functional update — required when the new value depends on the old one,
// especially with multiple updates in the same handler, loops, or async code
setCount((prev) => prev + 1);
3. The Async / Batched Nature of Updates
setState doesn’t update the variable immediately — it schedules a re-render. The count variable inside a given render is a snapshot; it doesn’t change mid-function.
function BuggyCounter() {
const [count, setCount] = useState(0);
const incrementThreeTimes = () => {
setCount(count + 1); // count is 0 here
setCount(count + 1); // count is STILL 0 here (same snapshot)
setCount(count + 1); // count is STILL 0 here
// Result: count becomes 1, not 3!
};
return <button onClick={incrementThreeTimes}>{count}</button>;
}
function FixedCounter() {
const [count, setCount] = useState(0);
const incrementThreeTimes = () => {
setCount((prev) => prev + 1); // 0 -> 1
setCount((prev) => prev + 1); // 1 -> 2
setCount((prev) => prev + 1); // 2 -> 3
// Result: count becomes 3 ✅
};
return <button onClick={incrementThreeTimes}>{count}</button>;
}
Automatic batching: since React 18, multiple setState calls inside a single event handler, promise callback, or timeout are batched into one re-render, instead of one re-render per call. This is why the functional-update pattern above matters — each prev => ... call correctly builds on the previous scheduled update, even though there’s only one actual re-render at the end.
4. Immutability Rules
React decides whether to re-render by comparing the old and new state references (Object.is), not by deeply checking values. Mutating an array or object in place keeps the same reference, so React thinks nothing changed.
Arrays:
// ❌ Mutating in place — same array reference, no re-render
function BuggyList() {
const [items, setItems] = useState(["a", "b"]);
const addItem = () => {
items.push("c"); // mutates the existing array
setItems(items); // same reference — React bails out
};
}
// ✅ Creating a new array — new reference, triggers re-render
function FixedList() {
const [items, setItems] = useState(["a", "b"]);
const addItem = () => {
setItems([...items, "c"]);
};
const removeItem = (index) => {
setItems(items.filter((_, i) => i !== index));
};
const updateItem = (index, newValue) => {
setItems(items.map((item, i) => (i === index ? newValue : item)));
};
}
Objects:
const [user, setUser] = useState({ name: "Ada", age: 30 });
// ❌ mutate
user.age = 31;
setUser(user);
// ✅ spread to create a new object
setUser({ ...user, age: 31 });
// ✅ nested update — spread at every level you're changing
setUser((prev) => ({
...prev,
address: { ...prev.address, city: "London" },
}));
5. Common Gotchas
Stale closures — a function captures the state value from the render it was created in, not the latest one.
function StaleClosureExample() {
const [count, setCount] = useState(0);
const handleClick = () => {
setTimeout(() => {
console.log(count); // logs the value from when handleClick ran, not 3s later
}, 3000);
};
return <button onClick={handleClick}>Log count in 3s</button>;
}
Setting state during render (without a guard) causes an infinite loop:
// ❌ Called on every render — React re-renders forever
function Broken() {
const [count, setCount] = useState(0);
setCount(count + 1); // should be inside an event handler or effect, not here
return <div>{count}</div>;
}
The key prop trick to reset state — changing a component’s key unmounts the old instance and mounts a fresh one, wiping all of its internal state:
// Switching userId gives each user a fully reset UserProfile,
// instead of manually resetting every piece of state on prop change
<UserProfile key={userId} userId={userId} />
Reading state you just “set” — this is really the same snapshot issue as batching:
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count); // still logs the OLD value — the update hasn't rendered yet
};
6. State Architecture & Design Decisions
Avoid storing derived state. If a value can be computed from existing state/props, compute it during render instead of syncing it into its own state variable.
// ❌ Derived value stored in its own state + synced with an effect
function Cart({ items }) {
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price, 0));
}, [items]);
return <p>Total: {total}</p>;
}
// ✅ Just compute it inline — no extra state, no sync bugs, no extra render
function Cart({ items }) {
const total = items.reduce((sum, i) => sum + i.price, 0);
return <p>Total: {total}</p>;
}
State colocation — keep state as close as possible to the component that uses it. Don’t lift it to a shared parent “just in case.”
Lifting state up — when two sibling components need to share state, move it to their closest common parent and pass it down.
function Parent() {
const [selected, setSelected] = useState(null);
return (
<>
<List onSelect={setSelected} />
<Details item={selected} />
</>
);
}
useState vs. useReducer:
// useState — fine for independent, simple fields
const [name, setName] = useState("");
const [email, setEmail] = useState("");
// useReducer — clearer when updates are interdependent or transitions are complex
function reducer(state, action) {
switch (action.type) {
case "field":
return { ...state, [action.field]: action.value };
case "reset":
return initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
const [state, dispatch] = useReducer(reducer, initialState);
dispatch({ type: "field", field: "email", value: "a@b.com" });
Rule of thumb: reach for useReducer when you have several state values that change together, or update logic that’s non-trivial enough to want unit testing on its own.
7. useState vs. Everything Else
useState vs. useRef — a ref updates without causing a re-render, and is safe to mutate directly:
function RefVsState() {
const [renderCount, setRenderCount] = useState(0); // triggers a re-render
const clickCountRef = useRef(0); // does NOT trigger a re-render
const handleClick = () => {
clickCountRef.current += 1; // safe to mutate directly
setRenderCount((c) => c + 1); // this is what actually updates the UI
};
}
useState vs. class components (for anyone coming from older code):
// Class component
class Counter extends React.Component {
state = { count: 0 };
increment = () => this.setState({ count: this.state.count + 1 });
render() {
return <button onClick={this.increment}>{this.state.count}</button>;
}
}
// Function component with useState
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
Newer React 19 hooks that often replace manual useState for forms/async UI — useActionState bundles pending/error/result state that people used to hand-roll with two or three separate useState calls:
import { useActionState } from "react";
function SubscribeForm() {
const [state, formAction, isPending] = useActionState(
async (prevState, formData) => {
const email = formData.get("email");
const res = await subscribe(email);
return res.ok ? { success: true } : { error: "Failed to subscribe" };
},
{ success: false },
);
return (
<form action={formAction}>
<input name="email" type="email" />
<button disabled={isPending}>Subscribe</button>
{state.error && <p>{state.error}</p>}
</form>
);
}
useState vs. external stores (Zustand, Redux, Jotai, Context) — local useState is enough until state needs to be shared across distant parts of the tree or persist beyond a component’s lifetime. Reaching for a global store too early is a common over-engineering trap.
8. 2026 Context: React Compiler & Newer Hooks
React Compiler reached a stable 1.0 release and now handles memoization automatically at build time, so a lot of manual useMemo/useCallback wrapping around state-derived values is no longer necessary:
// Pre-compiler (React 18 and earlier) — manual memoization
const filteredItems = useMemo(() => items.filter((i) => i.active), [items]);
const handleClick = useCallback(() => doSomething(id), [id]);
// With React Compiler — just write plain code;
// the compiler inserts the equivalent memoization for you
const filteredItems = items.filter((i) => i.active);
const handleClick = () => doSomething(id);
Worth flagging in the video: the compiler assumes your components are pure during render — no mutating state or props while rendering — so the immutability habits from Section 4 matter more than ever, not less.
Also worth a mention: the use() hook reads promises and context directly during render and integrates with Suspense, which eliminates a chunk of the manual “loading state + useEffect” pattern shown in Section 10.
9. Performance Considerations
Split state that changes independently, so an update to one piece doesn’t re-render UI that only cares about another piece:
// ❌ One state object — any change re-renders everything reading `form`
const [form, setForm] = useState({ query: "", filters: {}, sort: "asc" });
// ✅ Split into independent pieces
const [query, setQuery] = useState("");
const [filters, setFilters] = useState({});
const [sort, setSort] = useState("asc");
Remember: a state update re-renders the component that owns it and its entire subtree by default (unless children are memoized). This is the mental link between “where I put state” (Section 6) and “how much re-renders” (this section).
10. Hands-on Demos
Progressive examples to build live in the video, each introducing one new concept.
Toggle (boolean state)
function Toggle() {
const [isOn, setIsOn] = useState(false);
return (
<button onClick={() => setIsOn((prev) => !prev)}>
{isOn ? "ON" : "OFF"}
</button>
);
}
Controlled form input
function NameInput() {
const [name, setName] = useState("");
return (
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
/>
);
}
Todo list (array-of-objects immutability practice)
function TodoList() {
const [todos, setTodos] = useState([]);
const [text, setText] = useState("");
const addTodo = () => {
if (!text.trim()) return;
setTodos((prev) => [...prev, { id: Date.now(), text, done: false }]);
setText("");
};
const toggleTodo = (id) => {
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo,
),
);
};
const removeTodo = (id) => {
setTodos((prev) => prev.filter((todo) => todo.id !== id));
};
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={addTodo}>Add</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span
style={{ textDecoration: todo.done ? "line-through" : "none" }}
onClick={() => toggleTodo(todo.id)}
>
{todo.text}
</span>
<button onClick={() => removeTodo(todo.id)}>x</button>
</li>
))}
</ul>
</div>
);
}
Data fetching with loading/error/success state
function UserProfile({ userId }) {
const [status, setStatus] = useState("idle"); // 'idle' | 'loading' | 'success' | 'error'
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setStatus("loading");
fetchUser(userId)
.then((data) => {
if (!cancelled) {
setUser(data);
setStatus("success");
}
})
.catch((err) => {
if (!cancelled) {
setError(err.message);
setStatus("error");
}
});
return () => {
cancelled = true; // avoid setting state after the component unmounts
};
}, [userId]);
if (status === "loading") return <p>Loading...</p>;
if (status === "error") return <p>Error: {error}</p>;
if (status === "success") return <p>{user.name}</p>;
return null;
}
Good spot to circle back to Section 8 — in React 19, this pattern is increasingly replaced by
use()combined with Suspense boundaries.
Debounced search box
function SearchBox() {
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
useEffect(() => {
const timeoutId = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timeoutId); // cancel the previous timer on every keystroke
}, [query]);
// Trigger the actual search using debouncedQuery, not query
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
11. Cheat Sheet
| Situation | Do | Don’t |
|---|---|---|
| Updating based on the previous value | setCount(prev => prev + 1) | setCount(count + 1) repeatedly in one handler |
| Updating an array | setItems([...items, newItem]) | items.push(newItem) |
| Updating an object | setUser({ ...user, age: 31 }) | user.age = 31 |
| Expensive initial state | useState(() => computeExpensive()) | useState(computeExpensive()) |
| Resetting state when an identity changes | <Component key={id} /> | Manual reset logic in a useEffect |
| Derived values (totals, filtered lists) | Compute during render | Store in separate state + sync with useEffect |
| Values that don’t need to trigger a render | useRef | useState |
| Several interdependent fields / complex transitions | useReducer | A pile of separate useState calls |
| Form pending/error/result state (React 19+) | useActionState | Three separate useState calls |