Week 5

State with useState

Render and commit lifecycle

Event Handling

Forms

Practice

Assignment

Front end Track

What is State?

A real UI needs to remember things between interactions: which tab is open, what's typed in a search box, whether a modal is visible, what's in a shopping cart.

React calls this kind of per-component memory state. State is data that can change over time and, when it does, causes the component to re-render with the new values on screen.

You might wonder: why can't we just use a regular variable for this? Let's look at exactly why that doesn't work, because understanding the problem makes the solution click much faster.

Why Regular Variables Don't Work

Here's a counter component using a plain variable:

function Counter() {
  let count = 0; // regular variable

  function handleClick() {
    count = count + 1;
    console.log('Count is now:', count); // this logs correctly...
  }

  return (
    <div>
      <p>Count: {count}</p> {/* ...but this never updates */}
      <button onClick={handleClick}>Add one</button>
    </div>
  );
}

If you run this, you'll see the console logs incrementing correctly… so the variable is changing. But the screen never updates. Two things are working against you here:

  1. Local variables don't survive re-renders. Each time React renders Counter, it calls the function from scratch and resets count to 0. Any changes you made are gone.
  2. Changing a local variable doesn't trigger a re-render. React has no idea the variable changed, so it never updates the screen.

To update what's on screen, two things need to happen together: the new value must be stored somewhere React can find it between renders, and React must be told to re-render. useState does both!

Introducing useState

https://www.youtube.com/watch?v=SpDG283b4bw

useState is a Hook: a special function provided by React that lets you add state to a component. You import it and call it at the top level of your component:

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Add one</button>
    </div>
  );
}

Now every click updates the screen. Let's break down what's happening.

The Anatomy of useState

const [count, setCount] = useState(0);

useState(0) returns an array of exactly two items. You destructure it into a name pair: the current value and a function to update it. The 0 is the initial value, used only on the first render.

By convention, the pair is named [something, setSomething]. You'll see this pattern everywhere: [isOpen, setIsOpen], [email, setEmail], [items, setItems]. Follow it and it makes your code immediately readable to other React developers.

When you call setCount(count + 1), two things happen:

  1. React stores the new value and remembers it for next time.
  2. React schedules a re-render of the component.

On the next render, useState(0) is called again, but React ignores the 0 and instead returns the stored value. The component re-runs with the updated count, and the screen shows the new number.

<aside> πŸ’‘

Info

Hooks β€” functions starting with use β€” can only be called at the top level of your component, never inside conditions, loops, or nested functions. This is because React identifies each useState call by its position in the call order. If that order changes between renders (because a hook is inside an if block that sometimes runs and sometimes doesn't), React loses track of which state belongs to which variable. Your linter (eslint-plugin-react-hooks) will warn you if you break this rule.

</aside>

Multiple State Variables

A component can have as many state variables as it needs, each independent of the others:

function ProductCard({ product }) {
  const [quantity, setQuantity] = useState(1);
  const [isWishlisted, setIsWishlisted] = useState(false);
  const [reviewsVisible, setReviewsVisible] = useState(false);

  return (
    <div>
      <h2>{product.name}</h2>

      <button onClick={() => setIsWishlisted(!isWishlisted)}>
        {isWishlisted ? 'β™₯ Wishlisted' : 'β™‘ Add to wishlist'}
      </button>

      <input
        type="number"
        value={quantity}
        onChange={(e) => setQuantity(Number(e.target.value))}
        min="1"
      />
      <button>Add to cart</button>

      <button onClick={() => setReviewsVisible(!reviewsVisible)}>
        {reviewsVisible ? 'Hide reviews' : 'Show reviews'}
      </button>
      {reviewsVisible && <ReviewList productId={product.id} />}
    </div>
  );
}

Use separate state variables when the values are unrelated and change independently, as they do here. If you find yourself always changing two variables at the same time, that's a signal they might belong together in a single object.

<aside> πŸ’‘

Hands On

Build a LightSwitch component with a boolean state variable called isOn. Render a button that says "Turn on" when off and "Turn off" when on, and changes the background colour of a surrounding <div> based on the state. Try using the React DevTools browser extension to inspect your component's state live as you click.

</aside>

How Re-renders Work

Understanding the render cycle helps you avoid confusion later. When you call a setter function:

  1. React queues a re-render for the component.
  2. React re-runs the component function from top to bottom.
  3. useState returns the new value instead of the initial one.
  4. The new JSX is returned and React updates the DOM.

A critical detail: state doesn't change mid-render. Within a single event handler, the state value you read is fixed β€” it's a snapshot from when that render started.

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    console.log(count);      // 0
    setCount(count + 1);
    console.log(count);      // still 0 β€” count hasn't changed yet
    // The new value (1) will be available on the next render
  }

  return <button onClick={handleClick}>Count: {count}</button>;
}

This surprises many developers the first time. The setter doesn't mutate count in place: it schedules an update. The new value becomes available when the component re-renders.

Batching: React Groups Multiple Updates

React also batches state updates from within the same event handler. This means if you call multiple setters in one handler, React groups them into a single re-render rather than re-rendering after each one.

function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  function handleReset() {
    setFirstName('');  // React doesn't re-render yet
    setLastName('');   // React doesn't re-render yet
    // React re-renders once here, after the handler finishes
  }

  return (/* ... */);
}

This is generally what you want: batching makes React faster and avoids partially-updated UIs.

Functional Updates

There's one situation where the "snapshot" behaviour bites you: when you need to apply multiple updates to the same state variable in a single handler. Consider this:

// ❌ This only increments by 1, not 3
function handleTripleClick() {
  setCount(count + 1); // setCount(0 + 1) β†’ queues 1
  setCount(count + 1); // setCount(0 + 1) β†’ queues 1 again
  setCount(count + 1); // setCount(0 + 1) β†’ queues 1 again
}

All three calls read the same snapshot value of count (which is 0), so they all queue the same update. The result is 1, not 3.

The fix is to pass a function to the setter instead of a value. That function receives the most recent queued state as its argument:

// βœ… This correctly increments by 3
function handleTripleClick() {
  setCount(n => n + 1); // n is 0 β†’ queues 1
  setCount(n => n + 1); // n is 1 β†’ queues 2
  setCount(n => n + 1); // n is 2 β†’ queues 3
}

This is called a functional update (sometimes called an updater function). Instead of saying "set count to count + 1", you're saying "set count to whatever it currently is, plus 1." React processes the queue in order, feeding each result as input to the next.

You should use functional updates any time your new state value depends on the previous state value, particularly when the same state might be updated multiple times in quick succession.

<aside> πŸ’‘

Info

In practice, you won't need functional updates constantly, because most state updates are straightforward replacements. But they're the correct tool any time your next value derives from the previous one. A common real-world case: toggling. setIsOpen(prev => !prev) is more reliable than setIsOpen(!isOpen) when the toggle might fire multiple times rapidly.

</aside>

<aside> ⌨️

Hands On

Build a simple tally counter with "+" and "-" buttons. Then add a "+5" button that calls setCount(n => n + 1) five times in a row. Verify it increments by 5. Then try replacing those with setCount(count + 1) five times and confirm it only increments by 1. This makes the difference concrete.

</aside>

Updating State Correctly: Immutability

With primitives (numbers, strings, booleans), updating state is straightforward β€” you just pass the new value:

setCount(count + 1);
setName('Fatima');
setIsOpen(false);

Objects and arrays need more care. React detects state changes by comparing the old and new values. For objects and arrays, it does a reference comparison: it checks whether it's the same object in memory, not whether the contents are the same.

If you mutate an object that's already in state, the reference doesn't change, so React thinks nothing happened and skips the re-render.

Treating State as Read-Only

The rule from the React docs is worth remembering: treat everything you put into state as read-only. Even if JavaScript allows you to mutate an object, you shouldn't β€” not when that object lives in state.

// ❌ Mutating state directly β€” React won't see this change
function handleAgeChange() {
  user.age = 30; // same object reference, React doesn't re-render
  setUser(user);
}

// βœ… Creating a new object β€” React sees a new reference and re-renders
function handleAgeChange() {
  setUser({ ...user, age: 30 });
}

The spread operator (...user) copies all the existing properties into a new object, then you override the ones you want to change. This gives React a brand new object reference, which triggers the re-render correctly.

Updating Objects in State

Here's a realistic form example. When a user edits a field, you need to update just that field while keeping the rest of the data intact:

function ProfileForm() {
  const [profile, setProfile] = useState({
    name: 'Amara',
    email: '[email protected]',
    city: 'Amsterdam',
  });

  function handleChange(e) {
    setProfile({
      ...profile,              // copy all existing fields
      [e.target.name]: e.target.value, // override the one that changed
    });
  }

  return (
    <form>
      <input
        name="name"
        value={profile.name}
        onChange={handleChange}
        placeholder="Name"
      />
      <input
        name="email"
        value={profile.email}
        onChange={handleChange}
        placeholder="Email"
      />
      <input
        name="city"
        value={profile.city}
        onChange={handleChange}
        placeholder="City"
      />
      <p>
        {profile.name} Β· {profile.email} Β· {profile.city}
      </p>
    </form>
  );
}

[e.target.name] is computed property syntax, meaning it uses the input's name attribute as the property key. This means one handler can manage every field in the form, regardless of how many fields there are.

<aside> ⚠️

Warning

The spread operator is shallow: it only copies one level deep. If you have nested objects (an object inside an object), you'll need to spread at each level:

// ❌ This mutates the nested address object
setUser({ ...user, address: user.address });
user.address.city = 'Utrecht'; // still mutating!

// βœ… Spread at each level you're changing
setUser({
  ...user,
  address: {
    ...user.address,
    city: 'Utrecht',
  },
});

Deeply nested state is a signal that the state structure might need rethinking. Flat is easier.

</aside>

Updating Arrays in State

Arrays follow the same rule: never mutate, always replace. The React docs have a useful reference for which array methods to avoid and which to reach for:

| --- | --- | --- |

Adding an item:

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [input, setInput] = useState('');

  function handleAdd() {
    if (!input.trim()) return;

    setTodos([
      ...todos,
      { id: Date.now(), text: input, done: false },
    ]);
    setInput('');
  }

  return (
    <div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="Add a task..."
      />
      <button onClick={handleAdd}>Add</button>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </div>
  );
}

Removing an item β€” filter out the item you don't want:

function handleDelete(id) {
  setTodos(todos.filter((todo) => todo.id !== id));
}

Updating an item β€” use map to return a new array where the matching item is replaced: