🎛️ The useState Hook
Props let a component receive data. State lets a component remember data and change it over time. If props are the ingredients handed to a recipe, state is the oven temperature — a value the component owns and adjusts while it runs. The useState Hook is how a function component gets its own memory.
Week 4 · Day 2 (Tuesday: State and Lifecycle) · Lecture 1
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain what state is and how it differs from props
- Call
useStateand destructure its[value, setValue]return pair - Trigger a re-render by updating state instead of mutating a variable
- Update objects and arrays immutably with the spread operator
- Use functional updates when new state depends on the previous state
- Apply lazy initial state and obey the Rules of Hooks
Estimated Time: 70 minutes
Practice: Build an interactive counter, a live text mirror, and a to-do list — all driven by state.
In This Lesson
What Is State?
A plain variable inside a component is forgotten the instant the function finishes running. React calls your component function again on every render — so any let count = 0 resets to 0 each time. That's useless for a counter, a form, or anything that must persist between renders.
State is data a component owns that survives across renders and, when changed, tells React to re-run the component and update the screen. Think of a chameleon: its color (the UI) tracks its surroundings (the state). Change the state and the appearance follows automatically — you never touch the DOM by hand.
💡 Why not just a normal variable?
Two reasons. First, a local variable is wiped on every render, so it can't remember anything. Second, even a variable that survived wouldn't tell React that the screen needs updating. State solves both: React keeps the value between renders and re-renders when you change it through the setter.
Props vs State
Both hold data and both trigger re-renders, so beginners mix them up. The dividing line is ownership: props come from above and are read-only; state is owned and mutated within the component.
| Props | State |
|---|---|
| Passed in from the parent | Declared and owned inside the component |
| Read-only — the child never reassigns them | Updated via its setter function |
| Like function parameters | Like a value the function remembers between calls |
| Re-renders when the parent passes new values | Re-renders when you call the setter |
A useful test: "Can this component change this value on its own?" If yes, it's state. If the value only ever arrives from a parent, it's a prop.
Anatomy of useState
useState is a function React gives you. You call it with the initial value, and it hands back an array of exactly two things: the current value and a setter function to change it. We destructure that array immediately.
import { useState } from 'react';
function Counter() {
// useState returns a pair: [currentValue, setterFunction]
const [count, setCount] = useState(0); // 0 is the initial value
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}
[age, setAge]. The convention is x / setX.⚠️ Never assign to the state variable directly
Writing count = count + 1 does nothing useful — it changes a local variable React doesn't watch, and the value is wiped on the next render anyway. Always go through the setter: setCount(count + 1). The setter is what tells React "re-render me."
The State & Re-render Cycle
Here is the loop that powers every interactive React app. A user does something, you call the setter, React schedules a re-render, your function runs again with the new state, and the screen updates. Then it waits for the next event.
useState gives starting value] --> B[React shows the UI] B --> C{User interacts
click, type, submit} C --> D["You call setState(newValue)"] D --> E[React schedules a re-render] E --> F[Component function runs again
useState now returns the new value] F --> B
The critical mental shift: you never manually edit the DOM. You describe what the UI should look like for a given state, change the state, and React figures out the minimal DOM update. Your job is to keep state correct; rendering is React's job.
Everyday Examples
1. A counter
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
2. A controlled text input
The input's value comes from state and every keystroke updates that state. This "controlled input" pattern makes React the single source of truth for what's typed.
function TextMirror() {
const [text, setText] = useState('');
return (
<div>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Type something..."
/>
<p>You typed: {text}</p>
<p>Character count: {text.length}</p>
</div>
);
}
3. A boolean toggle
function ToggleSwitch() {
const [isOn, setIsOn] = useState(false);
return (
<button onClick={() => setIsOn(!isOn)}>
{isOn ? 'ON' : 'OFF'}
</button>
);
}
Multiple pieces of state
Call useState as many times as you need. Prefer several small state variables over one giant object — each is independent and easier to reason about.
function SignupForm() {
const [name, setName] = useState('');
const [age, setAge] = useState(0);
const [email, setEmail] = useState('');
return (
<form>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" />
<input type="number" value={age}
onChange={(e) => setAge(Number(e.target.value))} placeholder="Age" />
<input type="email" value={email}
onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
<p>{name} ({age}) — {email}</p>
</form>
);
}
💡 Tip: use Number(...), not parseInt
parseInt("") returns NaN, which shows up as "NaN" on screen. Number("") returns 0, a friendlier default for an empty number field.
Objects & Arrays: Update Immutably
State can hold objects and arrays, but there's one golden rule: never mutate them in place. React decides whether to re-render by comparing the reference of the new state to the old. If you edit the same object, the reference is unchanged and React sees "nothing changed."
⚠️ The classic mistake
// ❌ WRONG — mutates the existing object, same reference
const updateAge = () => {
user.age = 31; // React never sees a new object...
setUser(user); // ...so it skips the re-render
};
Instead, build a new object with the spread operator and change only what you need:
// ✅ CORRECT — new object, new reference, React re-renders
const updateAge = () => {
setUser(prevUser => ({ ...prevUser, age: 31 }));
};
Updating an object in state
function ShoppingCart() {
const [cart, setCart] = useState({ items: [], total: 0, discount: 0 });
const addItem = (item) => {
setCart(prev => ({
...prev, // copy every existing field
items: [...prev.items, item], // new array with the item appended
total: prev.total + item.price,
}));
};
return (
<div>
<p>Items: {cart.items.length} — Total: ${cart.total}</p>
<button onClick={() => addItem({ id: 1, name: 'Book', price: 20 })}>
Add Book ($20)
</button>
</div>
);
}
Updating arrays without mutating
Reach for the non-mutating array methods. They return a brand-new array, exactly what React wants.
| Goal | Use this (returns new array) | Avoid (mutates) |
|---|---|---|
| Add an item | [...arr, item] | arr.push(item) |
| Remove an item | arr.filter(x => x.id !== id) | arr.splice(...) |
| Change one item | arr.map(x => ...) | arr[i] = ... |
function TodoList() {
const [todos, setTodos] = useState([]);
const [draft, setDraft] = useState('');
const addTodo = () => {
if (!draft.trim()) return;
// Append with spread — a new array
setTodos([...todos, { id: Date.now(), text: draft, done: false }]);
setDraft('');
};
const toggle = (id) =>
// map returns a new array; only the matching item is replaced
setTodos(todos.map(t => t.id === id ? { ...t, done: !t.done } : t));
const remove = (id) =>
// filter returns a new array without the removed item
setTodos(todos.filter(t => t.id !== id));
return (
<div>
<input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Add a todo" />
<button onClick={addTodo}>Add</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input type="checkbox" checked={todo.done} onChange={() => toggle(todo.id)} />
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>{todo.text}</span>
<button onClick={() => remove(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
💡 key is required in lists
Each item rendered from an array needs a stable, unique key so React can track which item is which across renders. Use a real id (todo.id), never the array index if items can be reordered or removed.
Functional Updates & Batching
When the next state is computed from the current state, pass a function to the setter instead of a value. React calls it with the guaranteed-latest state, sidestepping stale values.
function Counter() {
const [count, setCount] = useState(0);
// ❌ Both reads see the SAME stale `count`, so this adds 1, not 2
const addTwoWrong = () => {
setCount(count + 1);
setCount(count + 1);
};
// ✅ Each callback receives the latest value, so this adds 2
const addTwoRight = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
};
return <button onClick={addTwoRight}>Count: {count}</button>;
}
Why? React batches multiple state updates in one event handler and only re-renders once. During that batch, count still holds the render-time value, so count + 1 reads the same number twice. The functional form prev => prev + 1 always operates on the freshest state React has.
✅ Rule of thumb
If your new value reads the old value — incrementing, toggling, appending — use the functional form setX(prev => ...). If you're setting a brand-new value that doesn't depend on the old one, passing the value directly is fine.
Lazy Init & Rules of Hooks
Lazy initial state
The argument to useState is only used on the first render, but if you write useState(expensiveCalc()), that function still runs on every render — its result is just ignored after the first. Pass a function instead and React calls it only once.
// ❌ expensiveCalc() runs on EVERY render (result ignored after the first)
const [data, setData] = useState(expensiveCalc());
// ✅ the function runs ONLY on the first render
const [data, setData] = useState(() => expensiveCalc());
// Handy for reading persisted values once:
const [count, setCount] = useState(() => {
const saved = localStorage.getItem('count');
return saved ? Number(saved) : 0;
});
The Rules of Hooks
Hooks rely on being called in the same order every render, so React can match each useState to its stored value. Two rules keep that order stable:
// ❌ WRONG — a Hook inside a condition changes call order
function Bad({ track }) {
if (track) {
const [count, setCount] = useState(0); // Error!
}
}
// ✅ CORRECT — Hook at the top level, condition on the usage
function Good({ track }) {
const [count, setCount] = useState(0);
if (track) {
// use count conditionally
}
}
💡 Let the linter guard you
The eslint-plugin-react-hooks package (bundled with Create React App and Vite's React template) flags Rules-of-Hooks violations as you type. Keep it on — it catches these before they become runtime bugs.
Practice & Quiz
🏋️ Exercise 1: A like button
Goal: Build a LikeButton that shows a heart and a count. Clicking toggles "liked" and adjusts the count by one. Use two pieces of state.
function LikeButton() {
// TODO: liked (boolean) and likes (number)
// Clicking should flip liked and add/remove one like
}
💡 Hint
Keep liked as a boolean and likes as a number. On click, toggle liked with setLiked(l => !l) and change the count with a functional update whose direction depends on the current liked.
✅ Solution
function LikeButton() {
const [liked, setLiked] = useState(false);
const [likes, setLikes] = useState(0);
const handleClick = () => {
setLikes(prev => prev + (liked ? -1 : 1));
setLiked(prev => !prev);
};
return (
<button onClick={handleClick}>
{liked ? '❤️' : '🤍'} {likes}
</button>
);
}
🏋️ Exercise 2: RGB color picker
Goal: Three sliders (0–255) for red, green, blue drive a live color swatch. Show the resulting rgb(...) string.
✅ Solution
function ColorPicker() {
const [rgb, setRgb] = useState({ r: 128, g: 128, b: 128 });
const set = (key) => (e) =>
setRgb(prev => ({ ...prev, [key]: Number(e.target.value) }));
const color = `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
return (
<div>
<div style={{ width: 120, height: 120, background: color }} />
<p>{color}</p>
{['r', 'g', 'b'].map(key => (
<input key={key} type="range" min="0" max="255"
value={rgb[key]} onChange={set(key)} />
))}
</div>
);
}
🎯 Quick Quiz
Question 1: What does useState(0) return?
Question 2: Why must you update objects in state immutably (with a copy)?
Question 3: When should you use the functional form setX(prev => ...)?
Best Practices & Pitfalls
✅ Do
- Split unrelated data into multiple
useStatecalls - Copy objects/arrays with spread before setting them
- Use functional updates when the new value reads the old one
- Name the pair consistently:
x/setX - Give lazy initializers as a function:
useState(() => ...)
❌ Don't
- Assign to the state variable directly (
count = 5) - Mutate state objects/arrays with
push,splice, orobj.x = ... - Call Hooks inside
if, loops, or nested functions - Expect state to change synchronously right after
setState— the new value shows up on the next render
⚠️ State updates aren't instant
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // 0 — NOT 1! count is still the render-time value
}
Calling the setter schedules a re-render; it doesn't reassign count in the running function. Read the new value on the next render, not right after the call.
Summary
🎉 Key Takeaways
- State is a component's own memory — it survives renders and triggers re-renders when changed
useState(initial)returns[value, setter]; only update through the setter- Update objects and arrays immutably with spread,
map, andfilter - Use the functional form
setX(prev => ...)when the next value depends on the previous one - State updates are batched and asynchronous — the new value appears on the next render
- Follow the Rules of Hooks: call them at the top level of a React function, in the same order every time
📚 Additional Resources
- react.dev —
useStatereference - react.dev — State: A Component's Memory
- react.dev — Updating Objects in State
- react.dev — Updating Arrays in State
🚀 What's Next?
Your components can now remember and change data. But real apps also talk to the outside world — fetching data, setting timers, subscribing to events. That's the job of the next lesson: the useEffect Hook, where you'll learn to run side effects after render and clean them up properly.
🎉 You gave your components memory!
Every interactive React feature — counters, forms, toggles, lists — is just state changing over time. You've got the core of it now.