Skip to main content

Handling Events

Handling Events

Written by Published

You pass a function to an event prop. Calling it yourself - the missing arrow - is the most common React mistake there is.

Pass the function, do not call it

onClick={handleClick} hands React the function to run later. onClick={handleClick()} runs it immediately during render and gives React whatever it returned.

The missing arrow, shown

jsx

// Counts how many times the function has actually run.
let calls = 0
function greet() {
  calls = calls + 1
  return "a string, not a handler"
}

function Buttons() {
  const [clicks, setClicks] = React.useState(0)

  return (
    <div>
      {/* WRONG: greet() runs right now, during render. React receives
          its return value — a string — which is not a handler at all. */}
      <button onClick={greet()}>Wrong — already ran</button>{" "}

      {/* RIGHT: React receives the function and calls it on click. */}
      <button onClick={() => { greet(); setClicks((c) => c + 1) }}>
        Right — runs on click
      </button>

      <p>greet() has run {calls} time(s). Clicks handled: {clicks}.</p>
      <p style={{ color: "#64748b" }}>
        Press the left button: nothing happens. Press the right one: the count moves.
      </p>
    </div>
  )
}

const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<Buttons />)

Run it and the counter already reads 1 before you touch anything - greet() ran during render. The left button is now wired to a string, so clicking it does nothing at all.

When the handler needs no arguments you can pass it directly: onClick={handleClick}. The arrow wrapper is only needed when you have to pass something.

Passing arguments

An arrow function is how you supply arguments without calling the handler early. The arrow is created on each render and called on each click.

Arguments through an arrow

jsx

function Picker() {
  const [chosen, setChosen] = React.useState("none")
  const colours = ["red", "green", "blue"]

  return (
    <div>
      <p>Chosen: {chosen}</p>
      {colours.map((colour) => (
        <button key={colour} onClick={() => setChosen(colour)} style={{ marginRight: 6 }}>
          {colour}
        </button>
      ))}
    </div>
  )
}

const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<Picker />)

The event object

React passes a synthetic event - a wrapper with the same API as the browser's event, normalised across browsers. preventDefault and stopPropagation work exactly as you expect.

Reading the event

jsx

function Form() {
  const [text, setText] = React.useState("")
  const [submitted, setSubmitted] = React.useState("")

  function handleSubmit(event) {
    event.preventDefault()   // without this the page reloads
    setSubmitted(text)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={(event) => setText(event.target.value)} />{" "}
      <button type="submit">Send</button>
      <p>Submitted: {submitted || "nothing yet"}</p>
    </form>
  )
}

const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<Form />)

Naming and the small print

  • Event props are camelCase: onClick, onChange, onSubmit, onMouseEnter.
  • Handler functions are conventionally named handleX; the prop that receives one is named onX.
  • Returning false does not cancel anything - call preventDefault.
  • React attaches one listener at the root rather than one per element, which is why adding thousands of handlers is cheap.

Handlers are recreated every render

The arrow you write inline is a brand new function on each render. That is normal and almost never a problem - React is not comparing handlers, and creating a function is cheap.

It only starts to matter when a child is memoised and a fresh function defeats that memoisation. That is a performance concern with a specific fix, covered later; reaching for useCallback before you have measured a problem adds noise for nothing.