← React for Beginners

Making it interactive: events and useState

Lesson 07 of 12 · 12 min

Everything so far has drawn a page from data that sits still. This lesson is where that stops, and it is the one that makes React click, so go slowly.

Handling a click

Events are props, and they are camel-cased. Pass a function:

function Counter() {
  function handleClick() {
    console.log("clicked");
  }

  return <button onClick={handleClick}>Click me</button>;
}

Get the single most common mistake here out of the way immediately:

<button onClick={handleClick}>   {/* right: passes the function */}
<button onClick={handleClick()}> {/* wrong: calls it during render */}

The second one runs handleClick while React is drawing the button, and passes its return value - usually undefined - as the handler. So it fires once, at the wrong time, and never again.

Inline arrow functions are fine and extremely common, especially when you need to pass something:

<button onClick={() => console.log("clicked")}>Click me</button>
<button onClick={() => remove(note.id)}>Delete</button>

That second one is not calling remove during render - it is creating a function that will call it, later, when clicked.

Why a plain variable does not work

Now make the counter count. The obvious attempt:

function Counter() {
  let count = 0;

  function handleClick() {
    count = count + 1;
    console.log(count);
  }

  return <button onClick={handleClick}>Clicked {count} times</button>;
}

Click it. The console shows 1, 2, 3. The button still says Clicked 0 times.

The number really is going up - count = count + 1 does exactly what it says. But nothing asked React to draw the button again, and the text on screen is the text from the render that already happened. React does not watch your variables. It re-runs a component when it is told to, and nothing here told it.

And even if something had: Counter is a function. Running it again executes let count = 0 again. The value would be thrown away every time.

So there are two problems, and they need one solution: a value that survives between renders, and that tells React to render again when it changes.

useState

That is what useState is:

import { useState } from "react";

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

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

  return <button onClick={handleClick}>Clicked {count} times</button>;
}

Now it counts.

useState(0) says “I need a piece of state, and the first time this component runs it should be 0”. It returns two things in an array, which the square brackets destructure:

  • count - the value for this render.
  • setCount - a function that stores a new value and tells React this component needs to run again.

The names are yours; [thing, setThing] is the universal convention and worth following.

Calling setCount does two things, and people usually only notice the first. It records the new value, and it schedules a re-render. React calls Counter() again, useState(0) hands back the value it kept rather than 0, and the returned JSX now says something different. That second job is the one a plain variable could never do.

The render loop: a click calls setCount, which stores the value and schedules a render; React runs the component again, useState returns the stored value, and React updates the DOM - leaving the page ready for the next click. You click the button setCount(1) store + schedule Counter() runs again DOM updated ready for the next one useState(0) returns 1 on the second run, not 0. The initial value is only used the first time through. A plain variable breaks the loop twice: nothing schedules, and re-running resets it.
Why a plain variable cannot do this: it has no way to schedule step three, and step three would wipe it anyway.

The 0 is only used on the very first render. After that it is ignored - it is an initial value, not a default.

State is per instance, not per component

Render the counter twice:

<Counter />
<Counter />

Two independent counts. Clicking one does not move the other.

”State belongs to the component” is easy to misread. The state belongs to the place in the tree where the component is used, not to the function. Two <Counter /> elements are two instances with two entirely separate pieces of state, exactly the way calling a function twice gives you two sets of local variables.

The updater form

Something that looks like it should work and does not:

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

You would expect two. You get one.

count is a const - a value captured for this particular render. If it is 0, then both lines read setCount(0 + 1), and both say “make it 1”. State is not a variable being incremented; it is a value being replaced, and both replacements are the same.

When the new value depends on the old one, pass a function instead:

function handleClick() {
  setCount((c) => c + 1);
  setCount((c) => c + 1);
}

Now it goes up by two. React queues the functions and runs each one on the result of the last, so it does not matter what count was when the handler started.

Use the updater form whenever the next value is computed from the current one. It is never wrong, and it is the only thing that is right inside anything asynchronous.

Never change state directly

The rule from lesson 4 comes back, for a different reason.

const [notes, setNotes] = useState(initialNotes);

function addNote(note) {
  notes.push(note);      // NO
  setNotes(notes);       // and this changes nothing
}

push mutates the array, so notes is still the same array it was. React compares the new value to the old one to decide whether anything changed, sees the identical array, and does not re-render. Your data changed and your screen did not.

Make a new one:

setNotes([...notes, note]);              // add
setNotes(notes.filter((n) => n.id !== id));  // remove
setNotes(notes.map((n) => (n.id === id ? { ...n, done: true } : n)));  // change one

filter and map already return new arrays, which is why they turn up everywhere in React. For sorting and reversing, which do not, copy first - [...notes].sort() or notes.toSorted(), exactly as in lesson 4.

The same goes for objects: never user.name = "x", always setUser({ ...user, name: "x" }).

Putting it together

The whole thing, and the piece lesson 5 owed you: a list you can actually remove things from.

import { useState } from "react";

const initialNotes = [
  { id: "a1", title: "Monday", body: "Started the course." },
  { id: "b2", title: "Tuesday", body: "Got as far as props." },
  { id: "c3", title: "Wednesday", body: "Lists, apparently." },
];

function App() {
  const [notes, setNotes] = useState(initialNotes);

  function removeNote(id) {
    setNotes(notes.filter((note) => note.id !== id));
  }

  return (
    <main>
      <h1>Notes</h1>

      {notes.length === 0 && <p>Nothing left.</p>}

      {notes.map((note) => (
        <section key={note.id}>
          <h2>{note.title}</h2>
          <p>{note.body}</p>
          <input placeholder="your thoughts" />
          <button onClick={() => removeNote(note.id)}>Delete</button>
        </section>
      ))}
    </main>
  );
}

export default App;

Every lesson so far is in there: components, props on key, a mapped list, a conditional with a real boolean on the left of &&, an event handler, state, and a copy rather than a mutation.

Now go and settle the argument from lesson 5. Type something into the box under Tuesday, then delete Monday. The text follows Tuesday, because the key is note.id. Now change that one line to key={index} - you will need (note, index) in the map - and do it again. This time the text ends up under Wednesday, and anything typed in the last row disappears entirely when you delete a row above it. That is the bug the last lesson could only describe, and it takes about thirty seconds to see for yourself.

Recap

Event handlers are functions passed as camel-cased props, and you pass the function rather than calling it. A plain variable cannot drive the screen because React does not watch variables and re-running the component would reset it anyway; useState solves both by keeping a value between renders and scheduling a new render when it is replaced. State belongs to each place a component is used, not to the function. Use the updater form when the next value depends on the last, and always replace state rather than modifying it - React decides whether to re-render by comparing values, and a mutated array is still the same array.

Next: forms, where every keystroke becomes a state update and React takes charge of what is in the box.