RizTech Academy logo
RizTech Academy
React FundamentalsLesson 3 of 730 min

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

  1. Build QuantityPicker with useState. Confirm the number changes.
  2. Log the state immediately after setting it. Explain why it is stale.
  3. Call setQuantity(quantity + 1) twice in one handler. Then fix it with the function form.
  4. Stop the quantity going below 1.
  5. Build a cart as state. Add items with push, see nothing happen, then fix it with a spread.
  6. Add remove and quantity-update using filter and map.
  7. Store the cart total in state, then find a way to make it disagree with the items. Replace it with a calculated value.
  8. Lift the quantity out of QuantityPicker into a parent and pass it down.
  9. 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