Lesson 3 • Beginner
State and Lifecycle ⚛️
By the end of this lesson you'll be able to give a component its own memory with useState, tell state apart from props, update objects and arrays the way React requires (without ever breaking re-rendering), and share one piece of state between components by lifting it up.
What You'll Learn
- Add state to a component with the useState hook
- Explain how the setter triggers a re-render
- Tell state apart from props (owned vs passed in)
- Update objects and arrays immutably with spread (...)
- Use functional updates (setX(prev => ...)) to avoid stale state
- Lift state up so two components share one source of truth
console.log; the React equivalent is shown in the comments. For the real thing, scaffold a project with npm create vite@latest.💡 Real-World Analogy
State is a component's memory. Think of a phone's screen-brightness slider. The current brightness is something the phone remembers — that's state. Each time you slide it, the phone updates the stored value and redraws the screen to match. Props, by contrast, are like the phone model printed on the box: handed to the device from outside and never changed by the device itself. In React, when state changes the component "redraws" (re-renders); when you mutate a value without telling React, nothing redraws — like changing the brightness number in a spec sheet and expecting the screen to dim.
1. useState — A Component's Memory
useState is a React Hook (a special function whose name starts with use) that gives a component a value it can remember between renders. You call it like const [count, setCount] = useState(0): the argument 0 is the initial value, and you get back a pair — the current value (count) and a setter function (setCount). The one rule that makes React work: only the setter changes state, and calling the setter is what tells React to re-render. Read this worked example, run it, and watch the setter trigger a "render".
Worked example: useState and re-rendering
Watch the setter cause a re-render, and see why direct assignment doesn't.
// React state lives inside the component. Our editor can't render UI,
// so we model what useState DOES with plain JS variables + console.log.
// In a real component the line would be: const [count, setCount] = useState(0);
// useState returns a PAIR: [currentValue, updaterFunction].
// We fake that pair so we can watch it behave like the real hook.
let count = 0; // the current value React holds for you
function setCount(next) { // the setter — in React
...In a real component this all lives inside the function. Here's the JSX it produces (read-only — the type annotation useState<number> pins the value to a number):
import { useState } from "react";
function Counter() {
const [count, setCount] = useState<number>(0);
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
);
}Every click calls setCount, React stores the new number, then re-runs Counter so the button text updates. That loop — event → setter → re-render — is the heartbeat of every interactive React app.
2. State vs Props
Beginners constantly confuse these two, so burn in the difference now. Props are passed into a component by its parent and are read-only — the component must never reassign them. State is owned by the component and is the only thing it's allowed to change (through its setter). A handy test: "Could this value ever change while the component is on screen, because of something the component itself does?" If yes, it's state; if it just arrives from outside, it's a prop.
Worked example: a prop you read vs state you change
See which value comes from outside and which the component owns.
// PROPS are passed IN by a parent. The component cannot change them.
// STATE is owned BY the component. The component changes it with its setter.
// Think of one <Greeting name="Ada" /> below.
// 'name' is a PROP: it comes from outside and is read-only here.
// 'likes' is STATE: it belongs to this component and changes on click.
let likes = 0; // STATE owned by Greeting
const name = "Ada"; // PROP handed down from the parent
function like() {
...3. Updating Objects Immutably
When state holds an object, you must never edit it in place (obj.age = 29). React decides whether to re-render by checking if the state is a new object — same object reference, no re-render. So you build a fresh copy with the spread operator ..., then override just the field you're changing: setUser({ ...user, age: 29 }). The spread copies every existing field; the field you list after it wins. Your turn — fill the blank.
🎯 Your turn: spread an object
Build a new object with one field changed. Fill in the ___ and check the expected output.
// 🎯 YOUR TURN — update ONE field of an object WITHOUT mutating it.
// Rule: never edit the old object. Build a NEW object with the spread (...).
const user = { name: "Sam", age: 28, city: "London" };
// 1) Make a NEW object that copies every field of 'user'
// but changes age to 29. Spread the old one first, then override age.
const updated = ___; // 👉 { ...user, age: 29 }
console.log("old:", JSON.stringify(user)); // old must be UNCHANGED
console.log("new:", JSON.stringify(u
...4. Updating Arrays Immutably
Arrays follow the same rule: produce a new array, never mutate the old one. That means avoiding push, pop, splice and sort (they change the array in place) and reaching instead for operations that return a new array: spread to add ([...todos, item]), filter to remove, and map to change one item. Fill in the two blanks below.
🎯 Your turn: add and remove from an array
Use spread to add and filter to remove. Fill in the ___ blanks.
// 🎯 YOUR TURN — update an ARRAY immutably (no push/splice/pop).
// Each step returns a BRAND NEW array; the original stays untouched.
const todos = ["Learn props", "Learn state"];
// 1) ADD an item. Spread the old array, then add the new string.
const added = ___; // 👉 [...todos, "Build a project"]
// 2) REMOVE "Learn props" using filter (keep everything that ISN'T it).
const removed = ___; // 👉 added.filter(t => t !== "Learn props")
console.log("original:", JSON.stringif
...5. Functional Updates — setX(prev => ...)
When the new state is built from the old state, pass the setter a function instead of a value: setCount(prev => prev + 1). React calls your function with the very latest value, so you can never accidentally work from a stale (outdated) snapshot — which is exactly what goes wrong if you fire several updates in a row using a plain variable. Run this to see the trap and the fix side by side.
Worked example: stale state vs functional update
Three +1 updates: one way gives 1, the right way gives 3.
// Functional update: setCount(prev => prev + 1).
// Use it whenever the NEW state depends on the OLD state.
let count = 0;
function setCount(updater) {
// React passes the LATEST value into your function and stores the result.
count = typeof updater === "function" ? updater(count) : updater;
}
console.log("=== The stale-value trap ===");
count = 0;
// Imagine reading 'count' once (0) and reusing that snapshot three times:
const snapshot = count; // snapshot = 0
setCount(sn
...🔎 Deep Dive: a reducer-like update function
Once updates get complex, teams centralise them in a single reducer — a pure function (state, action) => newState that always returns a brand-new state object and never mutates the old one (this is the idea behind useReducer and Redux). Notice every case below uses spread for the object and for the array, so the original is left untouched.
Worked example: a reducer that never mutates
One function handles increment, decrement and reset — all immutably.
// A reducer-like function: (state, action) -> NEW state.
// This is how big apps keep updates predictable. Notice: it never mutates.
const initial = { count: 0, history: [] };
function reducer(state, action) {
switch (action.type) {
case "increment":
return { ...state, count: state.count + 1, // new object
history: [...state.history, "inc"] }; // new array
case "decrement":
return { ...state, count: state.count - 1,
history:
...6. Lifting State Up
What if two components need the same piece of data — say one shows a count and another changes it? If each keeps its own copy, they drift out of sync. The fix is lifting state up: move the state to the nearest common parent, then pass the value down as a prop to one child and the setter down to the other. Now there is a single source of truth, and every child reads the same value.
Worked example: one shared count, two children
The parent owns the state; both children stay in sync.
// LIFTING STATE UP: when two components need the SAME data,
// move the state to their nearest common PARENT and pass it down as props.
//
// Before: each child had its own count -> they disagreed.
// After: the parent owns 'count' and passes it (+ the setter) to both.
// Parent owns the single source of truth:
let count = 0;
function setCount(next) { count = next; }
// Child A "displays" the shared value (it receives count as a PROP):
function Display(value) { console.log("Display shows:",
...Common Errors (and the fix)
- Mutating state directly:
todos.push(item)oruser.age = 29changes the value but keeps the same object, so React sees no change and the UI never updates. Make a copy:setTodos([...todos, item]),setUser({ ...user, age: 29 }). - Stale state without a functional update: calling
setCount(count + 1)several times in one handler reuses the same oldcountand only adds 1. UsesetCount(prev => prev + 1)so each update sees the latest value. - "Too many re-renders" / calling the setter during render: writing
<button onClick={setCount(5)}>runssetCountwhile rendering, which re-renders, which runs it again — an infinite loop. Pass a function:onClick={() => setCount(5)}. - Replacing instead of merging an object:
setUser({ age: 29 })throws awaynameandcity. Always spread first:setUser({ ...user, age: 29 }). - "Invalid hook call": calling
useStateinside a loop, condition, or a normal (non-component) function. Hooks must be called at the top level of a component, in the same order every render.
Pro Tips
- 💡 Treat state as read-only. Never edit it; always create the next value and hand it to the setter.
- 💡 Use a functional update whenever the new value depends on the old one — it's the safe default for counters, toggles, and lists.
- 💡 Keep state minimal. Don't store anything you can calculate from existing state or props during render.
- 💡 Lift state only as high as needed — to the nearest common parent, not all the way to the top.
📋 Quick Reference
| Task | Code |
|---|---|
| Declare state | const [val, setVal] = useState(initial) |
| Update value | setVal(newValue) |
| Functional update | setCount(prev => prev + 1) |
| Update an object | setObj({ ...obj, key: newVal }) |
| Add to array | setArr([...arr, item]) |
| Remove from array | setArr(arr.filter(x => x.id !== id)) |
| Change one array item | setArr(arr.map(x => x.id === id ? { ...x, done: true } : x)) |
Frequently Asked Questions
Q: Why doesn't my UI update when I change a variable directly?
Because React only re-renders when you call a setter. Assigning to a plain variable (or mutating state in place) changes the value but never tells React to redraw, so the screen stays stale. Always go through setState.
Q: What's the real difference between state and props?
Props are passed in from the parent and are read-only inside the component. State is owned by the component and is the only data it may change (via its setter). If a value can change because of what the component itself does, it's state.
Q: When do I need setX(prev => ...) instead of setX(value)?
Whenever the next value is calculated from the current one — counters, toggles, appending to a list. The functional form always receives the latest state, so multiple updates in a row don't clobber each other.
Q: Can I keep an object or array in state?
Yes — just update it immutably. Build a new object/array with spread (...), map, or filter and pass that to the setter; never push, splice, or assign to a field of the existing one.
Mini-Challenge: A Like Toggle
No blanks this time — just a brief and an outline. Write a toggle function that flips a "liked" flag and keeps an accurate count, using only immutable updates. Run it and check your output against the expected line in the comments. This is exactly the logic behind a real like button.
🎯 Mini-Challenge: build a like toggle
Flip liked true/false and keep count in sync — immutably.
// 🎯 MINI-CHALLENGE: a "like" toggle reducer (no UI needed)
// Build a single function that flips a button between liked / not liked
// and keeps an accurate like COUNT — using only immutable updates.
//
// 1. Start with: let state = { liked: false, count: 0 };
// 2. Write toggle(state) that returns a NEW state object:
// - flips state.liked (true <-> false)
// - if it just became liked, count goes UP by 1
// - if it just became unliked, count goes DOWN by 1
// (use { ...sta
...🎉 Lesson Complete
- ✅
useStategives a component memory:const [v, setV] = useState(initial) - ✅ The setter is what triggers a re-render — changing a variable directly does not
- ✅ Props come from the parent and are read-only; state is owned and changeable here
- ✅ Update objects/arrays immutably with spread,
map, andfilter— never mutate - ✅ Use functional updates
setX(prev => ...)when the new value depends on the old - ✅ Lift state up to a common parent so siblings share one source of truth
- ✅ Next lesson: Handling Events — wire buttons, inputs, and forms to your state
Sign up for free to track which lessons you've completed and get learning reminders.