React · lesson 5 of 22
Beginner
5Intermediate
12- 6 Effects and useEffect
- 7 You Might Not Need an Effect
- 8 The Rules of Hooks
- 9 useReducer and useContext
- 10 Custom Hooks
- 11 Measuring Re-renders: memo, useMemo, and useCallback
- 13 Routing with React Router
- 14 Portals with createPortal
- 15 Error Boundaries
- 16 Suspense and lazy
- 17 Reading Promises with use, No Framework Needed
- 18 Retrying After an Error with react-error-boundary
Advanced
5Also part of React
5Handling 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
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' ].