State and useState
Props come from outside and do not change. State is data a component owns and can change — a quantity, an open menu, a search box's contents. It is also where most React confusion lives, so this lesson goes slowly.
useState
"use client";
import { useState } from "react";
export function QuantityPicker() {
const [quantity, setQuantity] = useState(1);
return (
<div className="flex items-center gap-3">
<button onClick={() => setQuantity(quantity - 1)}>−</button>
<span>{quantity}</span>
<button onClick={() => setQuantity(quantity + 1)}>+</button>
</div>
);
}
useState(1) returns a pair: the current value and a function to change it. The
array destructuring is convention — you can name them anything, and
[thing, setThing] is what everyone writes.
"use client" at the top. In Next.js, components are server components by
default and cannot have state. Anything using useState needs that line. Module
3 explains why properly; for now, state means "use client".
What setting state actually does
This is the part worth understanding.
setQuantity(2) does not change quantity on the line after it:
function handleClick() {
setQuantity(quantity + 1);
console.log(quantity); // still the old value
}
It schedules a re-render. React calls your component function again, and that call gets the new value.
A component function runs from the top every time it re-renders. const [quantity, setQuantity] = useState(1) runs again — and returns the current
value, not 1, because React remembers it between renders. That is what the
hook is for.
This explains the classic:
function handleClick() {
setQuantity(quantity + 1);
setQuantity(quantity + 1); // adds 1, not 2
}
Both calls read the same stale quantity. Use the function form when the new
value depends on the old:
setQuantity((current) => current + 1);
setQuantity((current) => current + 1); // now adds 2
Use the function form whenever the next value depends on the previous one. It is always correct and costs nothing.
State must not be mutated
const [cart, setCart] = useState<CartItem[]>([]);
function addItem(item: CartItem) {
cart.push(item); // nothing happens
setCart(cart); // still nothing
}
The screen does not update. React compares the old and new values by identity — same array, so as far as it can tell nothing changed.
This is the aliasing problem, and the fix is to create a new value:
setCart([...cart, item]);
Objects the same way:
setUser({ ...user, name: "Priya" });
Nested updates get awkward:
setOrder({
...order,
address: { ...order.address, city: "Pune" },
});
When that starts to hurt, the state is probably too deeply nested — flatten it, or split it into several pieces of state.
Removing and updating items:
setCart(cart.filter((item) => item.id !== id));
setCart(
cart.map((item) =>
item.id === id ? { ...item, quantity: item.quantity + 1 } : item
)
);
filter and map return new arrays, which is exactly why they are used
constantly in React while push and splice are not.
Where state should live
Put state in the closest component that needs it. A dropdown's open/closed state belongs in the dropdown.
When two siblings need the same data, lift it to their nearest common parent and pass it down:
export function ProductPage({ product }: { product: Product }) {
const [quantity, setQuantity] = useState(1);
return (
<>
<QuantityPicker value={quantity} onChange={setQuantity} />
<AddToCartButton product={product} quantity={quantity} />
</>
);
}
QuantityPicker no longer owns the quantity — it receives it and reports
changes. That makes it a controlled component, and it is the standard shape
for anything whose value someone else cares about.
Lift state only as far as necessary. State at the top of an application re-renders everything below it, and makes components harder to reuse.
Do not put derived values in state
A common and costly mistake:
const [items, setItems] = useState<CartItem[]>([]);
const [total, setTotal] = useState(0); // do not
Now every change to items must also update total, and one path that forgets
leaves them disagreeing.
Calculate instead:
const [items, setItems] = useState<CartItem[]>([]);
const total = items.reduce((sum, item) => sum + item.pricePaise * item.quantity, 0);
It recalculates on every render, which is free at this scale, and it cannot be wrong.
If it can be worked out from existing state or props, do not store it. Same rule as computed properties in module 9 of the Python course.
Multiple pieces versus one object
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [form, setForm] = useState({ name: "", email: "" });
Separate variables are simpler and the default. One object is better when the
fields always change together, or when there are many — a form with eight fields
is eight useState calls otherwise.
With an object, remember to spread:
setForm({ ...form, name: "Priya" }); // not setForm({ name: "Priya" })
Forgetting the spread silently deletes the other fields, and it is worth causing once deliberately.
Initialising expensively
const [data, setData] = useState(expensiveCalculation()); // every render
const [data, setData] = useState(() => expensiveCalculation()); // once
The first calls the function on every render and throws the result away after the first. Passing a function defers it to the initial render only. Only matters when the work is genuinely expensive.
Check your work
Why logging state after setting it shows the old value: setting state schedules a re-render; the current call keeps the value it was rendered with.
Why two setQuantity(quantity + 1) calls add 1: both read the same stale
value. The function form setQuantity(q => q + 1) reads the latest.
Why push then setCart(cart) does nothing: React compares by identity and
it is the same array. Create a new one with a spread.
Why the total should not be state: it can be calculated from the items, and storing it means every path that changes items must remember to update it.
Where state should live: the closest component that needs it, lifted only as far as necessary.
Forgetting the spread in an object update: the other fields are deleted, because you replaced the whole object rather than merging.
Practice
- Build
QuantityPickerwithuseState. Confirm the number changes. - Log the state immediately after setting it. Explain why it is stale.
- Call
setQuantity(quantity + 1)twice in one handler. Then fix it with the function form. - Stop the quantity going below 1.
- Build a cart as state. Add items with
push, see nothing happen, then fix it with a spread. - Add remove and quantity-update using
filterandmap. - Store the cart total in state, then find a way to make it disagree with the items. Replace it with a calculated value.
- Lift the quantity out of
QuantityPickerinto a parent and pass it down. - Build a two-field form as one object. Forget the spread once and watch a field vanish.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship