Skip to main content
React · lesson 5 of 22
React beginner Lesson 5 of 22

Handling Events

Wire onChange and onSubmit with real user-event clicks and keystrokes, and see jsdom's real warning when a form submit is never prevented.

By SkillByExample · Published

react events forms

An event handler is a plain function passed as a prop named on plus the event, such as onChange or onSubmit. Every example here uses the Vitest + Testing Library + user-event project from the previous lesson.

onChange Fires Per Keystroke, onSubmit Needs preventDefault

A controlled input’s onChange runs on every keystroke; a form’s onSubmit needs e.preventDefault() or the browser tries to navigate.

// src/events/SearchBox.tsx
import { useState } from "react";

export function SearchBox({ onSearch }) {
  const [query, setQuery] = useState("");

  function handleSubmit(e) {
    e.preventDefault();
    onSearch(query.trim());
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search"
      />
      <button type="submit">Go</button>
    </form>
  );
}
// src/events/SearchBox.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SearchBox } from "./SearchBox.js";

describe("SearchBox", () => {
  it("fires onChange once per keystroke", async () => {
    const user = userEvent.setup();
    render(<SearchBox onSearch={() => {}} />);
    const input = screen.getByPlaceholderText("Search");
    let changeCount = 0;
    input.addEventListener("input", () => changeCount++);

    await user.type(input, "cats");

    console.log("onChange fired", changeCount, "times for", JSON.stringify("cats"));
    expect(changeCount).toBe(4);
    expect(input).toHaveValue("cats");
  });

  it("calls onSearch with the trimmed query and never reloads the page", async () => {
    const user = userEvent.setup();
    const onSearch = vi.fn();
    render(<SearchBox onSearch={onSearch} />);

    await user.type(screen.getByPlaceholderText("Search"), "  cats  ");
    await user.click(screen.getByRole("button", { name: "Go" }));

    expect(onSearch).toHaveBeenCalledExactlyOnceWith("cats");
  });
});
$ npx vitest run events/SearchBox.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

stdout | src/events/SearchBox.test.tsx > SearchBox > fires onChange once per keystroke
onChange fired 4 times for "cats"

 ✓ src/events/SearchBox.test.tsx > SearchBox > fires onChange once per keystroke 126ms
 ✓ src/events/SearchBox.test.tsx > SearchBox > calls onSearch with the trimmed query and never reloads the page 53ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  17:44:05
   Duration  33.94s (environment 66%, setup 22%, import 10%, transform 1%, tests 1%)

Listening for the DOM’s native input event directly (the one React’s onChange is wired to) confirms it: typing "cats" fires it 4 times, once per character — toHaveValue only checks the end result, which would look the same even if it had happened in one shot. The second test types extra spaces and clicks the real submit button, and onSearch is called with "cats", already trimmed — .trim() ran inside the handler, not in the component’s render.

Without preventDefault, jsdom Tries a Real Submit

Drop the e.preventDefault() call, and submitting shows what the browser would actually try to do.

// src/events/SearchBoxNoPreventDefault.tsx
import { useState } from "react";

export function SearchBoxNoPreventDefault({ onSearch }) {
  const [query, setQuery] = useState("");

  function handleSubmit(e) {
    onSearch(query.trim());
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search" />
      <button type="submit">Go</button>
    </form>
  );
}
// src/events/SearchBoxNoPreventDefault.test.tsx
import { it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SearchBoxNoPreventDefault } from "./SearchBoxNoPreventDefault.js";

it("without e.preventDefault(), the form tries a real submit", async () => {
  const user = userEvent.setup();
  const onSearch = vi.fn();
  render(<SearchBoxNoPreventDefault onSearch={onSearch} />);

  await user.type(screen.getByPlaceholderText("Search"), "cats");
  await user.click(screen.getByRole("button", { name: "Go" }));

  expect(onSearch).toHaveBeenCalledExactlyOnceWith("cats");
});
$ npx vitest run events/SearchBoxNoPreventDefault.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

Not implemented: HTMLFormElement's requestSubmit() method
 ✓ src/events/SearchBoxNoPreventDefault.test.tsx > without e.preventDefault(), the form tries a real submit 160ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  17:44:44
   Duration  33.86s (environment 68%, setup 21%, import 9%, transform 1%)

The test still passes — onSearch was still called — but jsdom logs Not implemented: HTMLFormElement's requestSubmit() method, because clicking a type="submit" button inside a form really does trigger the browser’s native submit behavior unless something stops it. jsdom doesn’t implement actual page navigation, so it logs this instead of silently doing nothing; a real browser would attempt to reload the page.

Practice

1. Add a counter that displays how many times onChange has fired, and confirm it matches the number of characters typed.

Solution
// src/events/practice/KeystrokeCounter.tsx
import { useState } from "react";

export function KeystrokeCounter() {
  const [value, setValue] = useState("");
  const [changeCount, setChangeCount] = useState(0);

  function handleChange(e) {
    setValue(e.target.value);
    setChangeCount((c) => c + 1);
  }

  return (
    <div>
      <input value={value} onChange={handleChange} placeholder="type here" />
      <p>onChange fired: {changeCount} times</p>
    </div>
  );
}
// src/events/practice/KeystrokeCounter.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { KeystrokeCounter } from "./KeystrokeCounter.js";

it("fires onChange once per typed character", async () => {
  const user = userEvent.setup();
  render(<KeystrokeCounter />);

  await user.type(screen.getByPlaceholderText("type here"), "hi!");

  expect(screen.getByText("onChange fired: 3 times")).toBeInTheDocument();
});
$ npx vitest run events/practice/KeystrokeCounter.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

 ✓ src/events/practice/KeystrokeCounter.test.tsx > fires onChange once per typed character 130ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  17:45:20
   Duration  32.19s (environment 68%, setup 21%, import 10%, transform 1%)

Three characters, "hi!", land on 3 — one onChange call per keystroke, confirmed by the component’s own counter in the rendered text (onChange fired: 3 times) rather than just trusting the claim.

2. Add an onKeyDown handler that displays the name of the last key pressed, including a non-printable key like Enter.

Solution
// src/events/practice/KeyReporter.tsx
import { useState } from "react";

export function KeyReporter() {
  const [lastKey, setLastKey] = useState("none");

  function handleKeyDown(e) {
    setLastKey(e.key);
  }

  return (
    <div>
      <input onKeyDown={handleKeyDown} placeholder="press a key" />
      <p>Last key: {lastKey}</p>
    </div>
  );
}
// src/events/practice/KeyReporter.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { KeyReporter } from "./KeyReporter.js";

it("reports the real key name from the keyboard event", async () => {
  const user = userEvent.setup();
  render(<KeyReporter />);
  const input = screen.getByPlaceholderText("press a key");

  await user.type(input, "x");
  expect(screen.getByText("Last key: x")).toBeInTheDocument();

  await user.type(input, "{Enter}");
  expect(screen.getByText("Last key: Enter")).toBeInTheDocument();
});
$ npx vitest run events/practice/KeyReporter.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

 ✓ src/events/practice/KeyReporter.test.tsx > reports the real key name from the keyboard event 171ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  17:45:55
   Duration  38.91s (environment 68%, setup 22%, import 9%, transform 1%)

e.key is "x" for a letter key and the string "Enter" for the Enter key — both come from the same event property, typed or not. (This component only reports keys; it has no onSubmit, so nothing is actually submitted here.)

3. Put a button inside a <div> with its own onClick, and check whether clicking the button also runs the div’s handler.

Solution
// src/events/practice/Bubbling.tsx
export function Bubbling({ onLog }) {
  return (
    <div onClick={() => onLog("div")}>
      <button onClick={() => onLog("button")}>Click</button>
    </div>
  );
}

export function BubblingStopped({ onLog }) {
  return (
    <div onClick={() => onLog("div")}>
      <button
        onClick={(e) => {
          e.stopPropagation();
          onLog("button");
        }}
      >
        Click
      </button>
    </div>
  );
}
// src/events/practice/Bubbling.test.tsx
import { it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Bubbling, BubblingStopped } from "./Bubbling.js";

it("a click on the button also fires the parent div's handler", async () => {
  const user = userEvent.setup();
  const log = [];
  render(<Bubbling onLog={(from) => log.push(from)} />);

  await user.click(screen.getByRole("button", { name: "Click" }));

  console.log("log:", log);
  expect(log).toEqual(["button", "div"]);
});

it("stopPropagation keeps the click from reaching the parent", async () => {
  const user = userEvent.setup();
  const log = [];
  render(<BubblingStopped onLog={(from) => log.push(from)} />);

  await user.click(screen.getByRole("button", { name: "Click" }));

  console.log("log:", log);
  expect(log).toEqual(["button"]);
});
$ npx vitest run events/practice/Bubbling.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

stdout | src/events/practice/Bubbling.test.tsx > a click on the button also fires the parent div's handler
log: [ 'button', 'div' ]

stdout | src/events/practice/Bubbling.test.tsx > stopPropagation keeps the click from reaching the parent
log: [ 'button' ]

 ✓ src/events/practice/Bubbling.test.tsx > a click on the button also fires the parent div's handler 166ms
 ✓ src/events/practice/Bubbling.test.tsx > stopPropagation keeps the click from reaching the parent 19ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  17:46:37
   Duration  34.70s (environment 61%, setup 26%, import 11%, transform 1%, tests 1%)

Without stopPropagation, the logged array is [ 'button', 'div' ] — the click runs the button’s own handler, then bubbles up and runs the div’s. Calling e.stopPropagation() in the button’s handler cuts that off, leaving only [ 'button' ].

Frequently Asked Questions

Does onChange fire on every keystroke in React?
Yes, for a text input. React's onChange is wired to the DOM's input event, which fires on every keystroke, not to the native change event, which only fires once the field loses focus. The naming is historical, not a typo.
Why call e.preventDefault() in a submit handler?
A <form>'s default browser behavior on submit is to navigate — reloading the page or following the action URL. Calling e.preventDefault() on the submit event stops that, so the handler's own logic (such as calling a callback with the form's values) is what actually happens instead.
What is a synthetic event?
The object React passes to a handler, such as onChange's e. It wraps the browser's real event with a consistent cross-browser API; e.nativeEvent holds the underlying DOM event. In React 17 and later it behaves like a normal event object and is not reused or pooled after the handler returns.
Does a click on a nested element also trigger its parent's onClick?
Yes, by default — this is event bubbling, the same as in plain DOM events. A click on a button inside a div with its own onClick runs the button's handler first, then the div's. Call e.stopPropagation() in the inner handler to stop it from reaching the parent.