State and Lifecycle
Reviewed & published by Brayan K
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.
Part of the free React course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
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
💡 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".
// 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 this RE-RENDERS
count = next; // React stores the new value...
render(); // ...then re-runs the component (we log it)
}
function render() { // stands in for React re-rendering the UI
console.log("RENDER -> count is", count);
}
render(); // RENDER -> count is 0 (first render)
console.log("--- user clicks +1 ---");
setCount(count + 1); // RENDER -> count is 1 (setter re-rendered!)
console.log("--- user clicks +1 again ---");
setCount(count + 1); // RENDER -> count is 2
console.log("--- the WRONG way ---");
count = 99; // assigning directly: value changes...
console.log("count is now", count); // count is now 99
// ...but React never re-rendered. No setCount() call = no render = stale UI.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):
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.
// 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() { // a button handler
likes = likes + 1; // (in React you'd call setLikes)
console.log(name + " has " + likes + " like(s)");
}
console.log("Prop name =", name, "(read-only, set by parent)");
console.log("State likes =", likes, "(owned here, changes over time)");
like(); // Ada has 1 like(s)
like(); // Ada has 2 like(s)
// You can READ a prop and put it in state, but you never reassign the prop.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 — 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(updated));
// 2) Confirm you made a copy, not edited in place:
console.log("different objects?", user !== updated);
// ✅ Expected output:
// old: {"name":"Sam","age":28,"city":"London"}
// new: {"name":"Sam","age":29,"city":"London"}
// different objects? true4. 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 — 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.stringify(todos)); // unchanged
console.log("added: ", JSON.stringify(added));
console.log("removed: ", JSON.stringify(removed));
// ✅ Expected output:
// original: ["Learn props","Learn state"]
// added: ["Learn props","Learn state","Build a project"]
// removed: ["Learn state","Build a project"]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.
// 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(snapshot + 1); // 1
setCount(snapshot + 1); // still uses 0 -> 1 (stale!)
setCount(snapshot + 1); // still 1
console.log("Using a stale snapshot, +1 three times:", count); // 1 (wrong)
console.log("=== The functional fix ===");
count = 0;
setCount(prev => prev + 1); // prev=0 -> 1
setCount(prev => prev + 1); // prev=1 -> 2
setCount(prev => prev + 1); // prev=2 -> 3
console.log("Using prev => prev + 1, +1 three times:", count); // 3 (correct)🔎 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.
// 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: [...state.history, "dec"] };
case "reset":
return { ...state, count: 0,
history: [...state.history, "reset"] };
default:
return state; // unknown action -> unchanged
}
}
let state = initial;
state = reducer(state, { type: "increment" }); // count 1
state = reducer(state, { type: "increment" }); // count 2
state = reducer(state, { type: "decrement" }); // count 1
state = reducer(state, { type: "reset" }); // count 0
console.log("final count:", state.count); // 0
console.log("history:", JSON.stringify(state.history)); // ["inc","inc","dec","reset"]
console.log("initial untouched?", initial.count === 0); // true6. 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.
// 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:", value); }
// Child B "changes" the shared value (it receives setCount as a PROP):
function PlusButton() { setCount(count + 1); }
Display(count); // Display shows: 0
PlusButton(); // child B updates the parent's state
Display(count); // Display shows: 1 <- both children stay in sync
PlusButton();
Display(count); // Display shows: 2
// One owner, passed down = no two children can ever disagree.Common Errors (and the fix)
- Mutating state directly: todos.push(item) or user.age = 29 changes 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 old count and only adds 1. Use setCount(prev => prev + 1) so each update sees the latest value.
- "Too many re-renders" / calling the setter during render: writing <button onClick={setCount(5)}> runs setCount while 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 away name and city. Always spread first: setUser({ ...user, age: 29 }).
- "Invalid hook call": calling useState inside 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: 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 { ...state, liked: ..., count: ... } — never edit state directly)
// 3. Call it a few times and log state.liked and state.count each time.
//
// ✅ Expected (start false/0): toggle -> true/1, toggle -> false/0, toggle -> true/1
// your code here🎉 Lesson Complete
- ✅ useState gives 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, and filter — 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
Practice quiz
What does const [count, setCount] = useState(0) return?
- Two copies of the same value
- An object with a value property
- A pair: the current value and a setter function
- The initial value only
Answer: A pair: the current value and a setter function. useState returns an array of two things: the current state value and a setter that updates it and triggers a re-render.
What actually causes React to re-render after a state change?
- Calling the setter function returned by useState
- Assigning directly to the state variable
- Mutating a property of the state object
- Any console.log call
Answer: Calling the setter function returned by useState. Only calling the setter tells React to store the new value and re-run the component. A plain assignment changes the value but never re-renders.
What is the key difference between props and state?
- Props are numbers; state is objects
- State is passed in by the parent; props are owned locally
- There is no difference
- Props are passed in by the parent and are read-only; state is owned by the component and changed via its setter
Answer: Props are passed in by the parent and are read-only; state is owned by the component and changed via its setter. Props arrive from outside and must never be reassigned inside the component. State belongs to the component and is the only data it may change.
How do you correctly update one field of an object held in state?
- user.age = 29
- setUser({ ...user, age: 29 })
- setUser({ age: 29 })
- user = { ...user, age: 29 }
Answer: setUser({ ...user, age: 29 }). Spread the old object to copy every field, then override the one you're changing. Mutating in place keeps the same reference so React sees no change.
Why does setUser({ age: 29 }) cause a bug when state is { name, age, city }?
- It replaces the whole object, throwing away name and city
- It throws a syntax error
- It mutates the original object
- It only works on arrays
Answer: It replaces the whole object, throwing away name and city. Passing a fresh object replaces state entirely. You must spread first ({ ...user, age: 29 }) to keep the other fields.
Which array operation is safe to use when updating state immutably?
- todos.push(item)
- todos.splice(0, 1)
- [...todos, item] (spread to a new array)
- todos.sort()
Answer: [...todos, item] (spread to a new array). push, splice, and sort mutate in place. Use spread to add, filter to remove, and map to change an item — each returns a brand-new array.
When should you use a functional update like setCount(prev => prev + 1)?
- Never; it's deprecated
- Whenever the new state is calculated from the old state
- Only inside useEffect
- Only for string state
Answer: Whenever the new state is calculated from the old state. The functional form receives the latest value, so multiple updates in a row don't clobber each other by reusing a stale snapshot.
What happens if you fire setCount(count + 1) three times in one handler using a plain count?
- Count goes up by 3
- React throws an error
- Count goes up by 2
- Count goes up by only 1 because each call reuses the same stale count
Answer: Count goes up by only 1 because each call reuses the same stale count. All three reads see the same old count, so the result is +1. Use setCount(prev => prev + 1) to get +3.
What does "lifting state up" mean?
- Moving state into a global variable
- Moving shared state to the nearest common parent and passing it down as props
- Storing state in localStorage
- Using more useState calls
Answer: Moving shared state to the nearest common parent and passing it down as props. When two components need the same data, the parent owns it as a single source of truth and passes the value (and setter) down, keeping siblings in sync.
Why does the 'Invalid hook call' error occur with useState?
- Because you used too many useState calls
- Because the initial value was null
- Because a hook was called in a loop, condition, or non-component function instead of the top level
- Because you forgot to import React
Answer: Because a hook was called in a loop, condition, or non-component function instead of the top level. Hooks must be called at the top level of a component in the same order every render — not inside loops, conditions, or ordinary functions.
Continue this course
- Previous: Components and Props
- Next: Props & One-Way Data Flow — Pass, destructure, and default props; children; and why data flows one way
- Quick reference: React cheat sheet › State & Events