React Events

Every interactive React app depends on events — clicks, keystrokes, form submissions, mouse movements — to know when something happened and what to do about it. React wraps the browser’s native event system in its own layer called synthetic events, giving you a consistent, cross-browser API that’s tightly integrated with the component render cycle. Understanding how React events actually work — not just how to type onClick — is essential for building predictable UIs and reasoning about why a screen updates when it does.

Overview: How Events Work in React

In plain HTML you attach event listeners with lowercase attributes like onclick="..." or by calling addEventListener. In React, you attach handlers using camelCase JSX props such as onClick, onChange, or onSubmit, and you pass an actual JavaScript function — never a string, and never the result of calling the function.

When the browser fires a native event (like a click), React doesn’t hand you that raw browser event directly. Instead it wraps it in a SyntheticEvent — an object that normalizes behavior across browsers so event.target, event.preventDefault(), and event.stopPropagation() work identically everywhere. A SyntheticEvent has the same interface as the native event it wraps, so nearly everything you already know about DOM events transfers directly.

Event Delegation

React does not attach a separate native listener to every single button or input you render. Instead, since React 17, it attaches one listener per event type to the root DOM container you rendered into (e.g. the element passed to createRoot), not to document as older versions did. When a native event bubbles up to that root, React looks at its internal component tree (the fiber tree) to figure out which component’s handler should run, then invokes it. This is why event delegation is efficient — adding a thousand buttons doesn’t mean a thousand native listeners.

Events and Re-renders

Event handlers are the most common place state gets updated. When your handler calls a state setter like setCount, React does not re-render immediately inside the handler. Instead, it batches all state updates that happen during that event and performs a single re-render afterward — this has been true for updates inside handlers since early React, and as of React 18 this automatic batching also applies inside promises, timeouts, and native event handlers. This batching is why calling a setter multiple times in one handler with stale values (like setCount(count + 1) twice) only increments once — each call closed over the same count from that render.

One historical note: in React 16 and earlier, SyntheticEvent objects were pooled and reused for performance, meaning you couldn’t read event properties asynchronously without calling event.persist() first. As of React 17+, event pooling was removed, so you can safely read event properties inside a setTimeout or after an await without needing persist().

Syntax

The general shape of an event handler in JSX:

<element eventProp={handlerFunction} />

// Passing an argument to the handler:
<element eventProp={(e) => handlerFunction(arg, e)} />
Event Prop Fires On Common Use
onClick Mouse click (or tap) Buttons, links, toggles
onChange Input value changes Controlled text/select/checkbox inputs
onSubmit Form submission Validating and sending form data
onKeyDown / onKeyUp Keyboard key pressed/released Keyboard shortcuts, Enter-to-submit
onFocus / onBlur Element gains/loses focus Field validation, highlighting
onMouseEnter / onMouseLeave Pointer enters/leaves an element Tooltips, hover effects
  • Event prop: a camelCase attribute matching the DOM event (e.g. onClick, not onclick).
  • Handler function: a reference to a function — React calls it for you; you never invoke it yourself in JSX.
  • Event object: React passes a SyntheticEvent as the first argument automatically when the handler is called directly.

Examples

Example 1: A Click Counter

import { useState } from "react";

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

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

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

export default Counter;

Output: Renders a button reading “Count: 0”; each click re-renders the button with the count incremented by 1.

Here handleClick is passed as a reference — React calls it only when the button is actually clicked. Each click triggers setCount, which schedules a re-render with the new value.

Example 2: A Controlled Form with preventDefault

import { useState } from "react";

function SignupForm() {
  const [email, setEmail] = useState("");

  function handleSubmit(e) {
    e.preventDefault();
    console.log(`Submitting: ${email}`);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <button type="submit">Sign Up</button>
    </form>
  );
}

export default SignupForm;

Output: Renders a labeled email field and a Sign Up button. Typing updates the field as you type; submitting logs Submitting: someone@example.com to the console and does not reload the page.

The input is controlled: its value comes from state, and every keystroke fires onChange, which updates that state. e.preventDefault() inside handleSubmit stops the browser’s default full-page form submission, which would otherwise reload the page and wipe out all React state.

Example 3: Passing Arguments to a Handler

import { useState } from "react";

function TodoList() {
  const [todos, setTodos] = useState(["Buy milk", "Walk dog", "Write code"]);

  function handleRemove(index) {
    setTodos(todos.filter((_, i) => i !== index));
  }

  return (
    <ul>
      {todos.map((todo, index) => (
        <li key={todo}>
          {todo}
          <button onClick={() => handleRemove(index)}>Remove</button>
        </li>
      ))}
    </ul>
  );
}

export default TodoList;

Output: Renders a bulleted list of three todos, each followed by a Remove button. Clicking a specific Remove button deletes only that todo from the list.

Since onClick only accepts a function reference, passing an argument requires wrapping the call in an inline arrow function: () => handleRemove(index). Each list item also has a stable key, which React needs to track items correctly across re-renders.

Example 4: Event Bubbling and stopPropagation

import { useState } from "react";

function BubblingDemo() {
  const [log, setLog] = useState([]);

  function handleOuterClick() {
    setLog((prev) => [...prev, "outer clicked"]);
  }

  function handleInnerClick(e) {
    e.stopPropagation();
    setLog((prev) => [...prev, "inner clicked"]);
  }

  return (
    <div onClick={handleOuterClick}>
      Outer
      <button onClick={handleInnerClick}>Inner button</button>
      <ul>
        {log.map((entry, i) => (
          <li key={i}>{entry}</li>
        ))}
      </ul>
    </div>
  );
}

export default BubblingDemo;

Output: Renders the text “Outer”, an “Inner button”, and a growing log list. Clicking the inner button logs only “inner clicked” (bubbling is stopped); clicking anywhere else inside the outer div logs “outer clicked”.

Synthetic events bubble just like native DOM events, traveling from the innermost element outward. Calling e.stopPropagation() in the inner handler prevents the outer div‘s onClick from also firing.

How It Works Step by Step

  • On mount: React attaches a single native listener per event type to the root container (not to every element). No per-element listeners exist yet.
  • On a user interaction: The browser dispatches a native event that bubbles to the root. React intercepts it, walks its fiber tree to find the matching component handler(s) along the path, and wraps the native event in a SyntheticEvent.
  • Handler invocation: React calls your function (e.g. handleClick), passing the SyntheticEvent as the argument.
  • State updates batch: Any state setters called during that handler are collected and applied together — React does not re-render after each individual setState call inside the same event.
  • Re-render and reconciliation: After the handler finishes, React re-renders the affected component(s), diffs the new Virtual DOM against the previous one, and commits only the DOM changes that actually differ.
  • On unmount: Because listeners live at the root rather than per-element, there’s no manual listener cleanup needed for JSX event props. Any subscriptions or timers you started (e.g. in a handler) still need their own cleanup, typically via a useEffect cleanup function.

Common Mistakes

Mistake 1: Calling the Handler Instead of Passing It

function Alerter() {
  function handleClick() {
    alert("Clicked!");
  }

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

This calls handleClick() immediately during render — the alert fires on every render, not on click — and passes its return value (undefined) as the handler. Fix it by passing the reference, not the call:

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

Mistake 2: Forgetting preventDefault on Forms

function SearchForm() {
  function handleSubmit() {
    console.log("searching...");
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" />
      <button type="submit">Search</button>
    </form>
  );
}

Without e.preventDefault(), the browser’s default form behavior triggers a full page reload, wiping out all component state. Always accept the event and call preventDefault:

function handleSubmit(e) {
  e.preventDefault();
  console.log("searching...");
}

Mistake 3: Stale State in Delayed Handlers

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

  function handleClick() {
    setTimeout(() => {
      setCount(count + 1);
    }, 1000);
  }

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

Each click captures the count value from the render it happened in. Clicking three times quickly schedules three timeouts that all read the same stale count, so the counter only ends up one higher instead of three. Use the functional updater form, which always receives the latest state:

function handleClick() {
  setTimeout(() => {
    setCount((prevCount) => prevCount + 1);
  }, 1000);
}

Best Practices

  • Always pass a function reference to event props — never call the function directly in JSX (onClick={handleClick}, not onClick={handleClick()}).
  • Use an inline arrow function only when you need to pass extra arguments or run a small expression; keep the logic itself in a named function for readability.
  • Always call e.preventDefault() in a form’s onSubmit unless you deliberately want the native browser submission.
  • Prefer the functional updater form (setCount(c => c + 1)) whenever a handler might run after a delay or could be called multiple times before a re-render.
  • Give every list item rendered from an array a stable, unique key, especially when each item also carries its own event handler.
  • Name handlers descriptively — handleClick, handleSubmit, handleChange — so their purpose is obvious at a glance.
  • Don’t worry about event pooling or calling event.persist() — that concern only applied to React 16 and earlier.

Practice Exercises

  • Build a ToggleButton component with a boolean isOn state that starts as false. Clicking the button should flip the state and display “ON” or “OFF” accordingly.
  • Build a controlled text input with a live character counter below it (e.g. “12 / 100”) that updates on every keystroke via onChange, plus a “Clear” button that resets the input back to an empty string.
  • Render a list of three colored box <div> elements. Clicking any box should log that box’s color to the console, using an inline arrow function to pass the color into a shared handler.

Summary

  • React event props are camelCase (onClick, onChange, onSubmit) and always take a function reference, not a string or a function call.
  • React wraps native browser events in SyntheticEvent objects for cross-browser consistency; since React 17 there’s no event pooling to worry about.
  • Since React 17, React attaches one listener per event type at the root container and uses bubbling internally, rather than a listener per element.
  • State updates inside an event handler are batched into a single re-render, so use the functional updater form when a handler might see stale state.
  • Always call e.preventDefault() in form submit handlers to stop full-page reloads.
  • Pass arguments to handlers with an inline arrow function; keep every list item’s handler paired with a stable key.