Goal: connect clicks, key presses and form submissions to state with event handler props, pass the right tool id to a handler, and take control of the browser’s default behaviour and of event propagation when you need to.
Events are where the tool library meets its members. A Reserve button that fires on page load, a search form that reloads the page, a card that opens when you only meant to press a button inside it: each of these is a small event-handling mistake, and each is easy to avoid once you know how React wires events up. This lesson covers the handful of rules that matter.
In this lesson
Pass a function, don’t call it
In React you attach a handler by passing a function to a prop named after the event, in camelCase: onClick, onChange, onSubmit, onKeyDown, onFocus. React calls that function when the event happens. The most common slip is to call the function while writing the JSX. Predict what the console shows before you click anything, then run it:
function ReserveButtons() {
function handleReserve(toolName) {
console.log('Reserving', toolName);
}
return (
<div>
<button onClick={() => handleReserve('Cordless drill')}>
Reserve drill
</button>
<button onClick={handleReserve('Tile cutter')}>
Reserve tile cutter
</button>
</div>
);
}
“Reserving Tile cutter” is logged straight away, during render, without a click. onClick={handleReserve('Tile cutter')} runs handleReserve while React builds the JSX and passes its return value, undefined, as the handler; clicking that button afterwards does nothing. The drill button wraps the call in an arrow function, so the call only happens on click. Click it and “Reserving Cordless drill” appears.
Here the mistake is only noisy. If the handler sets state, it is worse: setting state during render triggers another render, which calls the setter again, and React stops with a “Too many re-renders” error. So read every on…={…} as “here is a function for later”. Three forms are correct:
// 1. A named function defined in the component (no parentheses).
<button onClick={handleClear}>Clear basket</button>
// 2. An inline arrow, when you need to pass an argument.
<button onClick={() => handleRemove(tool.id)}>Remove</button>
// 3. A short inline arrow for a one-line update.
<button onClick={() => setOpen((o) => !o)}>Show details</button>
Define handlers inside the component so they can read its props and state. By convention, functions that handle an event are named handleSomething, and props that receive them are named onSomething. Keep business rules out of the JSX: a three-line arrow full of conditions belongs in a named handler where it can be read and tested.
Passing ids to handlers
In the catalogue, each tool row has a button, but the state lives in the page (Lesson 4 showed why: the page is the closest common parent of everything that needs it). The row receives a callback prop and calls it with its own tool’s id. The page decides what that means:
import { useState } from 'react';
const TOOLS = [
{ id: 't1', name: 'Cordless drill' },
{ id: 't3', name: 'Orbital sander' },
{ id: 't6', name: 'Tile cutter' },
];
function ToolRow({ tool, isReserved, onToggle }) {
const action = isReserved ? 'Cancel' : 'Reserve';
return (
<li>
{tool.name}{' '}
<button type="button" aria-label={`${action} ${tool.name}`}
onClick={() => onToggle(tool.id)}>{action}</button>
</li>
);
}
function Catalogue() {
const [reservedIds, setReservedIds] = useState([]);
function handleToggle(toolId) {
setReservedIds((prev) =>
prev.includes(toolId) ? prev.filter((id) => id !== toolId) : [...prev, toolId]
);
}
return (
<>
<ul>
{TOOLS.map((tool) => (
<ToolRow key={tool.id} tool={tool} onToggle={handleToggle}
isReserved={reservedIds.includes(tool.id)} />
))}
</ul>
<p>Reserved: {reservedIds.join(', ') || 'nothing yet'}</p>
</>
);
}
Click Reserve next to the drill and the page shows “Reserved: t1”. The row knows nothing about arrays or the basket: it reports “my tool’s button was pressed”, with the id. That keeps ToolRow reusable and puts the rule in one place. Each row’s arrow function captures its own tool, which is how three identical-looking buttons send three different ids.
Pass the id, not the index of the row or a copy of the whole tool. The index changes when the list is filtered or sorted (Lesson 7), and a copy can be out of date. The page already has the data and can look the tool up by id.
The event object
React calls your handler with an event object. It is a React wrapper around the browser’s event, with the same familiar properties and methods (target, currentTarget, key, preventDefault(), stopPropagation()) behaving the same way in every browser. If you ever need the raw browser event, it is on e.nativeEvent. Two properties are easy to confuse:
e.currentTargetis the element the handler is attached to: the button.e.targetis the element that was actually clicked, which may be something inside the button, such as an icon.
function handleClick(e) {
// A click on the <svg> icon inside the button:
// e.target is the svg (or one of its paths), e.currentTarget is the button.
console.log(e.currentTarget.dataset.toolId); // reliable
}
<button type="button" data-tool-id="t1" onClick={handleClick}>
<svg aria-hidden="true" focusable="false">…</svg> Reserve
</button>
When you need the element you attached the handler to, use currentTarget. In practice you rarely read ids from the DOM at all; an arrow function that passes tool.id is simpler. For inputs, e.target.value is the usual way to read what was typed (Lesson 8 covers forms properly).
One more detail matters as soon as a handler waits for something. currentTarget only points at the element while the event is being handled. After an await, that moment has passed. Click the button and read the console:
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function AvailabilityButton() {
async function handleCheck(e) {
const button = e.currentTarget; // read what you need first
await wait(300);
console.log('saved before await:', button.textContent);
console.log('read after await:', e.currentTarget);
}
return (
<button type="button" onClick={handleCheck}>
Check availability
</button>
);
}
The saved reference still works and logs “Check availability”; e.currentTarget read after the await is null. The habit that avoids this: at the top of an async handler, copy what you need from the event (the value, the element, the key) into variables, then do the slow work.
Forms and preventDefault
Some events come with a built-in browser behaviour. The important one is form submission: when a form is submitted, the browser sends it the traditional way, navigating to the form’s action URL (or reloading the current page if there isn’t one). In a React app that throws away all your state. When your handler takes over the submission, call e.preventDefault() first:
import { useState } from 'react';
function SearchForm() {
const [text, setText] = useState('sander');
const [searched, setSearched] = useState('');
function handleSubmit(e) {
e.preventDefault(); // we handle it; no page reload
setSearched(text.trim());
}
return (
<form role="search" onSubmit={handleSubmit}>
<label htmlFor="tool-search">Search tools</label>{' '}
<input id="tool-search" value={text}
onChange={(e) => setText(e.target.value)} />{' '}
<button type="submit">Search</button>{' '}
<button type="button" onClick={() => setText('')}>Clear</button>
{searched && <p>Showing results for "{searched}"</p>}
</form>
);
}
Press Search and the form shows ‘Showing results for “sander”‘ with no reload. Notice three choices in this small form:
- The handler is on the form’s
onSubmit, not the button’sonClick. Submitting fires for a click on the submit button and for Enter in the text field, so keyboard users get the same result for free. - Clear has
type="button". A<button>inside a form is a submit button unless you say otherwise, so without it, Clear would also submit the search. - The input has a real
<label>tied to it withhtmlFor(JSX’s name for theforattribute), so screen readers announce it and clicking the label focuses the field.
Only prevent defaults you are replacing. Calling preventDefault on a link’s click, for example, stops the navigation, which is right only if your code then navigates itself. React 19 also lets you pass a function to a form’s action prop, in which case React handles the submission (and the preventDefault) for you; Lesson 15 covers form actions. The onSubmit pattern here remains common and is worth knowing first.
Propagation: when the parent hears the click too
Most events bubble: after the handlers on the clicked element run, the event travels up through each ancestor and runs their handlers too. React handlers follow the same rule for almost every event; a few, such as onScroll, fire only on the element they are attached to. That surprises people when a clickable area contains a button. Here the whole card opens the tool’s details, and the card contains a Reserve button. Click Reserve:
import { useState } from 'react';
function ToolCardDemo() {
const [detailsFor, setDetailsFor] = useState('nothing');
const [reserved, setReserved] = useState(false);
function handleReserve(e) {
// Try adding: e.stopPropagation();
setReserved(true);
}
return (
<div>
<div onClick={() => setDetailsFor('Cordless drill')}
style={{ border: '1px solid #ccc', padding: 12 }}>
<p>Cordless drill</p>
<button type="button" onClick={handleReserve}>Reserve</button>
</div>
<p>Reserved: {reserved ? 'yes' : 'no'}</p>
<p>Details open for: {detailsFor}</p>
</div>
);
}
Both things happen: the drill is reserved and its details open, because the click bubbled from the button to the card. Uncomment e.stopPropagation();, run it again and click Reserve: now only “Reserved: yes” changes, and clicking elsewhere on the card still opens the details. stopPropagation stops the event travelling to ancestors; it is unrelated to preventDefault, which cancels the browser’s default action. They are often confused, and occasionally you need both.
Before reaching for stopPropagation, though, ask whether the parent should listen at all. The clickable <div> above works for mouse users only: it can’t be reached with Tab or activated with Enter, and screen readers don’t announce it as interactive. A better card makes the tool name itself a link or a “View details” button, next to the Reserve button rather than around it. Then nothing is nested, nothing bubbles into the wrong handler, and every action works from the keyboard. Keep stopPropagation for cases where a parent handler is genuinely needed, such as a dialog backdrop that closes when clicked but must ignore clicks inside the dialog.
For completeness: every bubbling event also has a capture version, such as onClickCapture, which runs on the way down before any normal handler. You will rarely need it; it is mostly used for things like logging every click.
Keyboard-friendly controls
Many members will use the catalogue with a keyboard or a screen reader. Most of the work is done for you if you pick the right element:
- Actions are
<button>s; navigation is<a href>. A button is focusable, shows a focus ring, is announced as a button, and firesonClickfor Enter and Space as well as for a mouse click. A<div onClick>has none of that, and addingtabIndex, a role and key handlers to imitate it is more code and easy to get wrong. - Give repeated buttons distinct names. A screen reader user who lists the buttons on the page hears “Reserve, Reserve, Reserve” unless each one says which tool it is for. The catalogue example above uses
aria-label={`${action} ${tool.name}`}, which starts with the visible word so voice-control users can still say “click Reserve”. - Use
onKeyDownfor real keyboard features, not to rebuild a button. Pressing Escape to clear the search field is a good example: checke.key === 'Escape'. Lesson 9 adds a page-wide “/” shortcut to focus the search.
Common mistakes
- Calling the handler in JSX.
onClick={handleRemove(tool.id)}runs during render. In review, anyon…={name(…)}without an arrow in front is suspect; with a state setter inside it ends in “Too many re-renders”. - Click handlers on
<div>or<span>. Tab through the page: if you can’t reach a control or press it with Enter, it should be a button or a link. - A button in a form without
type. It submits the form. Symptom: a “Clear” or “Remove” button that also runs the search, or reloads the page when there is nopreventDefault. - Reading the event after
await.e.currentTargetisnullby then. Copy values out of the event before the firstawait. stopPropagationas a patch. Several of them in one component usually mean interactive elements are nested inside each other. Fix the structure instead.
Build it: catalogue controls
Wire up the top of the catalogue page. The starter has the markup but no handlers:
- Submitting the search form (button or Enter) shows ‘Results for “…”‘ with the trimmed text, without reloading. Pressing Escape in the field empties it.
- Each tool row has an Add button that becomes Remove once the tool is in the basket. Rows call an
onToggle(toolId)prop; the page owns the basket state. - Every button has the right
typeand a name that includes the tool, and “In basket: 2” shows the count.
import { useState } from 'react';
const TOOLS = [
{ id: 't1', name: 'Cordless drill' },
{ id: 't6', name: 'Tile cutter' },
{ id: 't8', name: 'Lawn aerator' },
];
function ToolRow({ tool }) {
return (
<li>
{tool.name} <button>Add</button>
</li>
);
}
function CataloguePage() {
return (
<>
<form role="search">
<label htmlFor="q">Search tools</label> <input id="q" />{' '}
<button>Search</button>
</form>
<ul>
{TOOLS.map((tool) => <ToolRow key={tool.id} tool={tool} />)}
</ul>
<p>In basket: 0</p>
</>
);
}
Hint: you need three pieces of state in the page: the text in the field, the submitted search and the basket ids. Pass inBasket and onToggle to each row. The Escape check goes in the input’s onKeyDown.
Show a sample solution
import { useState } from 'react';
const TOOLS = [
{ id: 't1', name: 'Cordless drill' },
{ id: 't6', name: 'Tile cutter' },
{ id: 't8', name: 'Lawn aerator' },
];
function ToolRow({ tool, inBasket, onToggle }) {
const action = inBasket ? 'Remove' : 'Add';
return (
<li>
{tool.name}{' '}
<button type="button" aria-label={`${action} ${tool.name}`}
onClick={() => onToggle(tool.id)}>{action}</button>
</li>
);
}
function CataloguePage() {
const [text, setText] = useState('');
const [searched, setSearched] = useState('');
const [basketIds, setBasketIds] = useState([]);
function handleSubmit(e) {
e.preventDefault();
setSearched(text.trim());
}
function handleKeyDown(e) {
if (e.key === 'Escape') setText('');
}
function handleToggle(toolId) {
setBasketIds((prev) =>
prev.includes(toolId) ? prev.filter((id) => id !== toolId) : [...prev, toolId]
);
}
return (
<>
<form role="search" onSubmit={handleSubmit}>
<label htmlFor="search-q">Search tools</label>{' '}
<input id="search-q" value={text} onKeyDown={handleKeyDown}
onChange={(e) => setText(e.target.value)} />{' '}
<button type="submit">Search</button>
</form>
{searched && <p>Results for "{searched}"</p>}
<ul>
{TOOLS.map((tool) => (
<ToolRow key={tool.id} tool={tool} onToggle={handleToggle}
inBasket={basketIds.includes(tool.id)} />
))}
</ul>
<p>In basket: {basketIds.length}</p>
</>
);
}
The search is handled once, on the form, so the button and Enter behave the same. The rows only report which tool was pressed; the page decides what toggling means, using the updater pattern from Lesson 5. Nothing is clickable except real buttons, so the whole page works with Tab, Enter and Space. Lesson 7 will use the submitted search to filter the list.
Check your understanding
Answer each question in a sentence or two before opening the answer.
1. A “Remove filter” button inside the search form makes the page reload. What is the likely cause, and the fix?
The button has no type, so inside a form it is a submit button; clicking it submits the form, and without a preventDefault in an onSubmit handler the browser reloads the page. Give it type="button".
2. What is the difference between preventDefault and stopPropagation?
preventDefault cancels the browser’s built-in behaviour for the event, such as submitting a form or following a link. stopPropagation stops the event from reaching handlers on ancestor elements. One doesn’t imply the other.
3. How would you check that the Reserve buttons on the catalogue work for keyboard users?
Put the mouse away and press Tab through the page: each Reserve button should receive a visible focus ring, and both Enter and Space should reserve the tool. A screen reader (or the browser’s accessibility panel) should announce a button whose name includes the tool. In the code, check that the control is a <button> rather than a clickable <div>. Automated tests can do the same with keyboard events (Lesson 49).
Key takeaways
- Event props take a function:
onClick={handleX}oronClick={() => handleX(id)}, neveronClick={handleX(id)}. - Children report events with an id through
on…props; the component that owns the state decides what happens. - Handle forms in
onSubmitwithe.preventDefault(), and give every non-submit buttontype="button". - Events bubble to ancestors. Prefer layouts where interactive elements aren’t nested; use
stopPropagationwhen a parent handler is genuinely needed. - Use real buttons and links so keyboard and screen reader support comes for free, and read event values before any
await.
Priority: should know. You will write event handlers in every component, so these rules should become habits; look up the full list of events and their properties when you need one. See Responding to Events and the React event object reference in the React documentation.
