Episode 2 — React Frontend Architecture NextJS / 2.3 — State and Rerendering Logic
2.3.b — useState Hook
In one sentence:
useStateis React's built-in hook that gives a component a piece of state: a value that persists between renders and a function to update that value (triggering a re-render).
Navigation: ← 2.3.a — What Is State · Next → 2.3.c — How React Re-renders
Table of Contents
- Syntax and Basic Usage
- How useState Works Under the Hood
- Initial Values
- Updating State
- Functional Updates
- State with Different Data Types
- Updating Objects in State
- Updating Arrays in State
- Updating Nested State
- Multiple useState Calls
- Lazy Initialization
- Common Mistakes
- useState vs useReducer
- Debugging State with React DevTools
- Practical Examples
- Key Takeaways
- Explain-It Challenge
1. Syntax and Basic Usage
The useState hook follows a consistent pattern:
import { useState } from "react";
function MyComponent() {
const [value, setValue] = useState(initialValue);
// ^^^^^ ^^^^^^^^ ^^^^^^^^^^^^
// | | |
// | | Initial value (used on first render only)
// | Setter function (call this to update state and trigger re-render)
// Current state value (read this to use the state)
}
Anatomy of the useState Call
const [count, setCount] = useState(0);
Breaking this down:
useState(0)-- calls the hook with initial value0- Returns an array with exactly two elements:
[currentValue, setterFunction] - Array destructuring assigns
count(the value) andsetCount(the setter)
Why Array Destructuring?
useState returns an array, not an object. This lets you name the variables whatever you want:
const [count, setCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [username, setUsername] = useState("");
const [items, setItems] = useState([]);
// Convention: [thing, setThing]
A Complete Example
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
What happens when the button is clicked:
1. User clicks button
2. onClick handler fires: setCount(0 + 1)
3. React queues a state update: count should become 1
4. React schedules a re-render of Counter
5. Counter() runs again
6. useState(0) returns [1, setCount] -- the 0 is ignored, stored value (1) is used
7. New JSX is returned with count = 1
8. React updates the DOM: "You clicked 1 times"
2. How useState Works Under the Hood
Understanding the internals helps you avoid bugs and write better code.
The Hook Linked List
React doesn't use the variable name to track which useState call maps to which state. It uses call order. Each component instance maintains an ordered list of hooks.
Component Instance: <Counter />
Hook List:
[0] useState --> value: 5 (count)
[1] useState --> value: true (isOpen)
[2] useEffect --> cleanup: fn
[3] useState --> value: "hello" (text)
On every render, React walks through this list in order. The first useState call gets slot 0, the second gets slot 1, etc.
Why Hooks Must Be Called in the Same Order
// BAD: Conditional hook call
function BadComponent({ showExtra }) {
const [count, setCount] = useState(0);
if (showExtra) {
const [extra, setExtra] = useState(""); // VIOLATION!
}
const [name, setName] = useState("Alice");
}
When showExtra is true: Slot 0 = count, Slot 1 = extra, Slot 2 = name
When showExtra is false: Slot 0 = count, Slot 1 = name (React thinks this is extra!)
This is why the Rules of Hooks exist:
- Only call hooks at the top level (not inside conditions, loops, or nested functions)
- Only call hooks from React function components or custom hooks
Fiber Architecture
Each component instance corresponds to a Fiber node. The Fiber stores:
Fiber Node:
+---------------------------+
| type: Counter |
| stateNode: DOM element |
| memoizedState: ---------> Hook 0 (count: 5)
| |
| next: -----> Hook 1 (isOpen: true)
| |
| next: -----> Hook 2 (...)
| pendingProps: {...} |
| updateQueue: [...] |
+---------------------------+
The memoizedState field points to the first hook, forming a linked list.
The Update Queue
When you call setCount(newValue), React creates an update object and adds it to a queue:
setCount(5)
Update Object:
{
action: 5,
next: null,
lane: DefaultLane,
}
This gets added to the hook's update queue.
On next render, React processes the queue to compute the final state.
3. Initial Values
The argument to useState is only used on the first render. After that, React ignores it.
Primitive Initial Values
const [count, setCount] = useState(0); // number
const [name, setName] = useState(""); // string
const [isVisible, setIsVisible] = useState(false); // boolean
const [selected, setSelected] = useState(null); // null
Object and Array Initial Values
const [user, setUser] = useState({
name: "",
email: "",
age: 0,
});
const [items, setItems] = useState([]);
const [position, setPosition] = useState({ x: 0, y: 0 });
Function Initial Values (Lazy Initialization)
If the initial value is expensive to compute, pass a function instead:
// BAD: This function runs on EVERY render, result thrown away after first
const [data, setData] = useState(expensiveCalculation());
// GOOD: This function runs only on the FIRST render
const [data, setData] = useState(() => expensiveCalculation());
useState(expensiveCalculation())
--> expensiveCalculation() called immediately, every render
--> Result used only on first render
useState(() => expensiveCalculation())
--> Function reference passed to useState
--> React calls it only on render 1
--> No wasted computation on subsequent renders
Common Lazy Initialization Patterns
// Reading from localStorage
const [theme, setTheme] = useState(() => {
const saved = localStorage.getItem("theme");
return saved ? JSON.parse(saved) : "light";
});
// Expensive computation
const [grid, setGrid] = useState(() => {
return Array.from({ length: 100 }, (_, row) =>
Array.from({ length: 100 }, (_, col) => ({
row,
col,
value: computeInitialValue(row, col),
}))
);
});
// Parsing URL parameters
const [filters, setFilters] = useState(() => {
const params = new URLSearchParams(window.location.search);
return {
category: params.get("category") || "all",
sort: params.get("sort") || "newest",
};
});
4. Updating State
There are two ways to call the setter function.
Direct Value Update
const [count, setCount] = useState(0);
setCount(5); // Set count to 5
setCount(count + 1); // Set count to current count + 1
setCount(0); // Reset count to 0
Functional Update (Updater Function)
const [count, setCount] = useState(0);
setCount(prev => prev + 1); // Increment based on previous value
setCount(prev => prev * 2); // Double the previous value
setCount(prev => Math.max(prev - 1, 0)); // Decrement but don't go below 0
When to Use Which
| Scenario | Use Direct | Use Functional |
|---|---|---|
| Setting to a known value | setCount(0) | |
| Based on user input | setName(e.target.value) | |
| Based on previous state | setCount(prev => prev + 1) | |
| Multiple updates in one handler | Always | |
| Inside setTimeout/setInterval | Always | |
| Toggling a boolean | setIsOpen(prev => !prev) |
The Stale Closure Problem
function Counter() {
const [count, setCount] = useState(0);
function handleTripleIncrement() {
// BAD: Uses the closure value of count, which is 0 for all three
setCount(count + 1); // 0 + 1 = 1
setCount(count + 1); // 0 + 1 = 1
setCount(count + 1); // 0 + 1 = 1
// Result: count becomes 1, not 3
// GOOD: Uses functional updates
setCount(prev => prev + 1); // 0 + 1 = 1
setCount(prev => prev + 1); // 1 + 1 = 2
setCount(prev => prev + 1); // 2 + 1 = 3
// Result: count becomes 3
}
}
5. Functional Updates
Functional updates solve several real problems.
Problem 1: Multiple Updates in One Event Handler
Use prev => when you need multiple sequential updates (shown above).
Problem 2: Stale Closures in Timeouts
function Timer() {
const [count, setCount] = useState(0);
function startCounting() {
// BAD: count is always 0 in this closure
setInterval(() => {
setCount(count + 1); // Always 0 + 1 = 1
}, 1000);
// GOOD: Functional update always uses the latest value
setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
}
}
Problem 3: Event Handlers Created Once
function SearchResults() {
const [results, setResults] = useState([]);
const handleNewResult = useCallback((result) => {
// BAD: results might be stale if this callback was memoized
setResults([...results, result]);
// GOOD: Always uses latest state
setResults(prev => [...prev, result]);
}, []);
}
The Rule of Thumb
If the new state depends on the old state, use functional updates. If you're setting state to a completely new independent value, direct updates are fine.
// Direct updates -- fine:
setName("Alice");
setCount(0);
setIsOpen(false);
// Functional updates -- needed:
setCount(prev => prev + 1);
setItems(prev => [...prev, item]);
setIsOpen(prev => !prev);
6. State with Different Data Types
Strings
function NameInput() {
const [name, setName] = useState("");
return (
<input
value={name}
onChange={e => setName(e.target.value)}
placeholder="Enter your name"
/>
);
}
Numbers
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(prev => prev - 1)}>-</button>
<span>{count}</span>
<button onClick={() => setCount(prev => prev + 1)}>+</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
Booleans
function TogglePanel() {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(prev => !prev)}>
{isOpen ? "Close" : "Open"} Panel
</button>
{isOpen && <div className="panel">Panel content here</div>}
</div>
);
}
Null
function UserProfile() {
const [user, setUser] = useState(null);
if (user === null) {
return <p>No user selected</p>;
}
return <p>Welcome, {user.name}</p>;
}
7. Updating Objects in State
Objects in state must be replaced, never mutated.
Updating a Single Field
const [person, setPerson] = useState({
firstName: "Alice",
lastName: "Smith",
age: 30,
});
function updateFirstName(newName) {
setPerson({ ...person, firstName: newName });
}
// With functional update
function updateAge(newAge) {
setPerson(prev => ({ ...prev, age: newAge }));
}
Dynamic Field Updates
function handleChange(e) {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
}
This pattern is common in forms:
function Form() {
const [formData, setFormData] = useState({
username: "",
email: "",
password: "",
});
function handleChange(e) {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
}
return (
<form>
<input name="username" value={formData.username} onChange={handleChange} />
<input name="email" value={formData.email} onChange={handleChange} />
<input name="password" value={formData.password} onChange={handleChange} />
</form>
);
}
8. Updating Arrays in State
Quick Reference Table
| Operation | Mutating (BAD) | Immutable (GOOD) |
|---|---|---|
| Add to end | arr.push(item) | [...arr, item] |
| Add to start | arr.unshift(item) | [item, ...arr] |
| Remove | arr.splice(i, 1) | arr.filter(...) |
| Replace | arr[i] = newItem | arr.map(...) |
| Sort | arr.sort() | [...arr].sort() |
| Reverse | arr.reverse() | [...arr].reverse() |
| Clear | arr.length = 0 | [] |
Adding Items
setItems(prev => [...prev, "Cherry"]); // Add to end
setItems(prev => ["Cherry", ...prev]); // Add to beginning
Removing Items
setItems(prev => prev.filter(item => item.id !== id)); // Remove by id
setItems(prev => prev.filter((_, i) => i !== index)); // Remove by index
Updating Items
setItems(prev => prev.map(item =>
item.id === id ? { ...item, name: newName } : item
));
Toggling a Field
setItems(prev => prev.map(item =>
item.id === id ? { ...item, complete: !item.complete } : item
));
Sorting
setItems(prev => [...prev].sort((a, b) => a.name.localeCompare(b.name)));
9. Updating Nested State
Every level of nesting requires a new copy.
Two Levels Deep
const [user, setUser] = useState({
name: "Alice",
address: { street: "123 Main St", city: "Portland" },
});
// Update city
setUser(prev => ({
...prev,
address: { ...prev.address, city: "Seattle" },
}));
Three Levels Deep
const [company, setCompany] = useState({
name: "Acme",
ceo: {
name: "Alice",
contact: { email: "alice@acme.com", phone: "555-0100" },
},
});
setCompany(prev => ({
...prev,
ceo: {
...prev.ceo,
contact: { ...prev.ceo.contact, email: "newalice@acme.com" },
},
}));
For deeply nested state, consider:
- Flattening the state into separate
useStatecalls - Using structuredClone for deep copies
- Using Immer for write-like syntax with immutable results
10. Multiple useState Calls
A component can have as many useState calls as it needs.
When to Use Multiple useState
function UserForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [age, setAge] = useState(0);
// Each piece of state is independent
}
When to Use a Single Object
function UserForm() {
const [form, setForm] = useState({ name: "", email: "", age: 0 });
function handleChange(field, value) {
setForm(prev => ({ ...prev, [field]: value }));
}
}
Decision Guide
| Factor | Multiple useState | Single Object |
|---|---|---|
| Fields change independently | Better | Fine |
| Fields change together | Fine | Better |
| Many fields (5+) | Gets verbose | Cleaner |
| Reset all at once | Must reset each | setForm(initialState) |
11. Lazy Initialization
When the initial state requires an expensive computation, use a function:
// BAD: expensiveCalculation runs on EVERY render
const [data, setData] = useState(expensiveCalculation());
// GOOD: expensiveCalculation runs only on FIRST render
const [data, setData] = useState(() => expensiveCalculation());
Don't use lazy initialization for cheap values:
// These are fine without lazy initialization
const [count, setCount] = useState(0);
const [name, setName] = useState("");
const [items, setItems] = useState([]);
12. Common Mistakes
Mistake 1: Mutating State Directly
// BAD: Direct mutation
user.name = "Bob";
setUser(user);
// GOOD: Create new object
setUser({ ...user, name: "Bob" });
Mistake 2: Reading State Immediately After Setting It
function handleClick() {
setCount(count + 1);
console.log(count); // Still the old value!
// Fix: compute separately
const newCount = count + 1;
setCount(newCount);
console.log(newCount);
}
Mistake 3: Setting State in the Render Body
function BadComponent() {
const [count, setCount] = useState(0);
setCount(count + 1); // INFINITE LOOP!
return <p>{count}</p>;
}
Mistake 4: Stale Closures
When using setInterval or setTimeout, use functional updates to avoid capturing stale state values.
Mistake 5: Unnecessary State
// BAD: Derived value stored in state
const [items, setItems] = useState([...]);
const [filteredItems, setFilteredItems] = useState([]);
useEffect(() => {
setFilteredItems(items.filter(i => i.active));
}, [items]);
// GOOD: Compute during render
const [items, setItems] = useState([...]);
const filteredItems = items.filter(i => i.active);
13. useState vs useReducer
| Criterion | useState | useReducer |
|---|---|---|
| Number of state values | 1-3 | 3+ related values |
| Update logic complexity | Simple | Complex |
| Next state depends on multiple values | Rarely | Often |
| Testing state logic independently | Harder | Easy (reducer is pure function) |
Rule of thumb: Start with useState. Switch to useReducer when you find yourself with multiple related state values and complex update logic.
14. Debugging State with React DevTools
Viewing State
- Install React Developer Tools (browser extension)
- Open DevTools, go to "Components" tab
- Click on any component
- See hooks/state values in the right panel
Editing State Live
Click on a state value in DevTools and change it. The component re-renders immediately.
Highlighting Re-renders
In DevTools settings, enable "Highlight updates when components render." Components that re-render flash with a colored border.
The Profiler Tab
- Go to "Profiler" tab
- Click record
- Interact with the app
- Click stop
- See which components rendered, why, and how long each took
15. Practical Examples
Example 1: Controlled Text Input
function TextInput() {
const [text, setText] = useState("");
return (
<div>
<input type="text" value={text} onChange={e => setText(e.target.value)} />
<p>You typed: {text}</p>
<p>Character count: {text.length}</p>
</div>
);
}
Example 2: Todo List
function TodoList() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState("");
function addTodo(e) {
e.preventDefault();
if (!input.trim()) return;
setTodos(prev => [...prev, { id: Date.now(), text: input.trim(), done: false }]);
setInput("");
}
function toggleTodo(id) {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
));
}
function removeTodo(id) {
setTodos(prev => prev.filter(todo => todo.id !== id));
}
const remaining = todos.filter(t => !t.done).length;
return (
<div>
<form onSubmit={addTodo}>
<input value={input} onChange={e => setInput(e.target.value)} placeholder="Add todo..." />
<button type="submit">Add</button>
</form>
<p>{remaining} items remaining</p>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input type="checkbox" checked={todo.done} onChange={() => toggleTodo(todo.id)} />
<span style={{ textDecoration: todo.done ? "line-through" : "none" }}>{todo.text}</span>
<button onClick={() => removeTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
Example 3: Accordion
function Accordion({ items }) {
const [openIndex, setOpenIndex] = useState(null);
function toggleItem(index) {
setOpenIndex(prev => (prev === index ? null : index));
}
return (
<div>
{items.map((item, index) => (
<div key={index}>
<button onClick={() => toggleItem(index)}>
{item.title} {openIndex === index ? "[-]" : "[+]"}
</button>
{openIndex === index && <div style={{ padding: "8px 16px" }}>{item.content}</div>}
</div>
))}
</div>
);
}
Example 4: Color Picker
function ColorPicker() {
const [color, setColor] = useState({ r: 100, g: 150, b: 200 });
function handleChange(channel, value) {
setColor(prev => ({ ...prev, [channel]: Number(value) }));
}
const hex = `#${color.r.toString(16).padStart(2, "0")}${color.g.toString(16).padStart(2, "0")}${color.b.toString(16).padStart(2, "0")}`;
return (
<div>
<div style={{ width: 100, height: 100, backgroundColor: hex, border: "1px solid #000" }} />
<p>Hex: {hex}</p>
{["r", "g", "b"].map(channel => (
<label key={channel}>
{channel.toUpperCase()}: {color[channel]}
<input
type="range"
min={0}
max={255}
value={color[channel]}
onChange={e => handleChange(channel, e.target.value)}
/>
</label>
))}
</div>
);
}
Example 5: Image Gallery
function ImageGallery({ images }) {
const [currentIndex, setCurrentIndex] = useState(0);
function goNext() {
setCurrentIndex(prev => (prev + 1) % images.length);
}
function goPrev() {
setCurrentIndex(prev => (prev - 1 + images.length) % images.length);
}
return (
<div>
<img src={images[currentIndex].src} alt={images[currentIndex].alt} />
<div>
<button onClick={goPrev}>Previous</button>
<span>{currentIndex + 1} / {images.length}</span>
<button onClick={goNext}>Next</button>
</div>
</div>
);
}
Example 6: Shopping Cart Item
function CartItem({ product }) {
const [quantity, setQuantity] = useState(1);
const total = product.price * quantity;
return (
<div>
<h3>{product.name}</h3>
<p>${product.price.toFixed(2)} each</p>
<div>
<button onClick={() => setQuantity(prev => Math.max(prev - 1, 1))}>-</button>
<span>{quantity}</span>
<button onClick={() => setQuantity(prev => Math.min(prev + 1, product.stock))}>+</button>
</div>
<p>Total: ${total.toFixed(2)}</p>
</div>
);
}
Example 7: Tab Component
function Tabs({ tabs }) {
const [activeTab, setActiveTab] = useState(0);
return (
<div>
<div role="tablist">
{tabs.map((tab, index) => (
<button
key={index}
role="tab"
aria-selected={index === activeTab}
onClick={() => setActiveTab(index)}
style={{
fontWeight: index === activeTab ? "bold" : "normal",
borderBottom: index === activeTab ? "2px solid blue" : "none",
}}
>
{tab.label}
</button>
))}
</div>
<div role="tabpanel">{tabs[activeTab].content}</div>
</div>
);
}
Example 8: Password Visibility Toggle
function PasswordInput() {
const [password, setPassword] = useState("");
const [isVisible, setIsVisible] = useState(false);
return (
<div>
<input
type={isVisible ? "text" : "password"}
value={password}
onChange={e => setPassword(e.target.value)}
/>
<button type="button" onClick={() => setIsVisible(prev => !prev)}>
{isVisible ? "Hide" : "Show"}
</button>
</div>
);
}
Example 9: Multi-Field Registration Form
function RegistrationForm() {
const [form, setForm] = useState({
username: "",
email: "",
password: "",
confirmPassword: "",
});
function handleChange(e) {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
}
const passwordsMatch = form.password === form.confirmPassword;
const isValid = form.username && form.email && form.password && passwordsMatch;
return (
<form>
<input name="username" value={form.username} onChange={handleChange} placeholder="Username" />
<input name="email" value={form.email} onChange={handleChange} placeholder="Email" />
<input name="password" type="password" value={form.password} onChange={handleChange} placeholder="Password" />
<input name="confirmPassword" type="password" value={form.confirmPassword} onChange={handleChange} placeholder="Confirm" />
{!passwordsMatch && form.confirmPassword && <p>Passwords do not match</p>}
<button disabled={!isValid}>Register</button>
</form>
);
}
Example 10: Stopwatch
function Stopwatch() {
const [time, setTime] = useState(0);
const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef(null);
function start() {
if (isRunning) return;
setIsRunning(true);
intervalRef.current = setInterval(() => {
setTime(prev => prev + 10);
}, 10);
}
function stop() {
setIsRunning(false);
clearInterval(intervalRef.current);
}
function reset() {
stop();
setTime(0);
}
const minutes = Math.floor(time / 60000);
const seconds = Math.floor((time % 60000) / 1000);
const milliseconds = Math.floor((time % 1000) / 10);
const display = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(milliseconds).padStart(2, "0")}`;
return (
<div>
<p style={{ fontSize: "2rem", fontFamily: "monospace" }}>{display}</p>
<button onClick={start} disabled={isRunning}>Start</button>
<button onClick={stop} disabled={!isRunning}>Stop</button>
<button onClick={reset}>Reset</button>
</div>
);
}
Key Takeaways
useStatereturns[value, setter]-- value to read, setter to update.- Initial value is used only on the first render. After that, React uses the stored value.
- Use lazy initialization (
useState(() => ...)) for expensive initial computations. - Use functional updates (
setValue(prev => ...)) when new state depends on old state. - Never mutate state directly. Always create new objects/arrays.
- State updates are asynchronous. You don't get the new value until the next render.
- Hooks must be called in the same order on every render.
- Multiple useState calls are fine -- group related data, separate unrelated data.
- Flatten deeply nested state to avoid painful spread chains.
- Start with useState, graduate to useReducer when update logic gets complex.
Explain-It Challenge
Pick one and explain it without using the word "state" or "render":
- Why does calling
setCount(count + 1)three times only add 1, not 3? - Why can't you just change
user.name = "Bob"and have the screen update? - What's the difference between
useState(heavyFunction())anduseState(heavyFunction)(no parentheses)?
Try explaining to someone who knows JavaScript but has never seen React.
Navigation: ← 2.3.a — What Is State · Next → 2.3.c — How React Re-renders