React120 min total · 17 parts
React Fundamentals: Components, Hooks, and the Virtual DOM
Part 6 of 17 · ~3 min
Handling Events
Our rows are clickable — clicking anywhere on one opens the drawer — and each row also contains a Reject button. Those two facts are about to fight.
React attaches handlers through camelCase props that take a function:
function CandidateRow({ candidate, onSelect, onStageChange }) {
return (
<tr onClick={() => onSelect(candidate.id)}>
<td>{candidate.name}</td>
<td>
<button onClick={() => onStageChange(candidate.id, "rejected")}>
Reject
</button>
</td>
</tr>
);
}
Click Reject. The candidate is rejected, and the drawer opens.
The click happened on the button, which is inside the row, so the event bubbles up through the row and fires the row's handler too. Both handlers are correct in isolation; together they are wrong. The button has to say that this click was for it:
<button
onClick={(e) => {
e.stopPropagation(); // this click is not also a row click
onStageChange(candidate.id, "rejected");
}}
>
Reject
</button>
The same call appears in Drawer from chapter 2, for the same reason: clicking the backdrop should close the drawer, and a click inside the panel is not a backdrop click, even though it bubbles through one.
Passing arguments, and the bug that looks like sabotage
A JSX event prop wants a function reference. Hand it the result of calling a function and React stores that result as the handler:
<button onClick={onStageChange(candidate.id, "rejected")}>Reject</button>
Every candidate in the table is rejected the instant the table renders. Not on click — on render. onStageChange(...) is evaluated while the element is being created, which is what the parentheses mean. Whatever it returns (undefined, probably) is what gets registered as the click handler.
Wrap it, and the call moves to click time:
<button onClick={() => onStageChange(candidate.id, "rejected")}>Reject</button>
The distinction is "here is a function you can call later" versus "here is what happened when I called it just now". It is the same distinction as setTimeout(fn, 100) against setTimeout(fn(), 100), and it catches people in both places.
What React is actually doing underneath
The e your handler receives is not the browser's native event. It is a SyntheticEvent, React's cross-browser wrapper, with the standard interface (preventDefault, stopPropagation, target, currentTarget) normalised across browsers. The real one is always available at e.nativeEvent when you need it.
(If you have read older advice about "event pooling" — that the event object is recycled and you must call e.persist() to read it asynchronously — that was removed in React 17. The event object you get today is an ordinary object you can hold onto.)
React also does not attach a native listener to each of your eight hundred rows. It attaches a small number of listeners at the root container where your app is mounted, and works out which component's handler should run by walking the tree, using delegation. Before React 17 those root listeners lived on document; since React 17 they are on the container element, which is what lets two React versions or two React roots coexist on one page without fighting.
Most application code never needs to know this. One consequence occasionally does matter, though: call e.stopPropagation() inside one of these handlers, and every other React handler further up gets skipped, in the same bubbling order your JSX nesting already implies. If you have also attached a native listener directly to document — a "click outside to dismiss" helper, a third-party analytics script — that listener is outside React's system, and the ordering between the two is not what your JSX suggests. Stopping the native event as well takes e.nativeEvent.stopImmediatePropagation().
preventDefault is the other half, and the drawer's review form needs it, because a <form> submit reloads the page by default:
function ReviewForm({ onSave }) {
function handleSubmit(e) {
e.preventDefault(); // stay on the page; we'll do the save ourselves
onSave();
}
return <form onSubmit={handleSubmit}>...</form>;
}
Keep the submit handler on the <form> rather than on the button. That way the Enter key works, which for a form a reviewer uses forty times a day is not a small detail.