After this lesson you write event handlers, pass them down as props, predict the order in which nested handlers run, and stop propagation or a form reload.
Each LogButton prints its own message prop. You click the second button, Share. What is printed?
function LogButton({ message, children }) {
return (
<button onClick={() => console.log(message)}>
{children}
</button>
);
}
function Actions() {
return (
<div>
<LogButton message="Saving!">Save</LogButton>
<LogButton message="Sharing!">Share</LogButton>
</div>
);
}
Brain teaser · Activity 6 of 7
Brain teaser. This is the worked example below. The toolbar has both onClickCapture and onClick, and Button stops propagation. You click Upload Image. What is printed, in order?
Mini-task: a card with a Like button. In main.jsx, write a Card component with the props title, onOpen and onLike. It renders an <article> that calls onOpen when clicked, with the title in an <h2> and a Like button inside. The Like button calls onLike, and a click on it must not also open the card. The default export, App, renders one Card and passes handlers that print "open card" and "liked". Run it, click Like, then the title: you should see liked, then open card.
Check your work against this list
Build it yourself
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
A toolbar that catches and stops clicks
App passes two handlers to Toolbar as onPlayMovie and onUploadImage, named after what they do. Toolbar hands onPlayMovie to a plain <button>, and onUploadImage to Button, which calls e.stopPropagation() and then the handler. The toolbar <div> has an onClickCapture and an onClick. A run only renders the page, so the output is the HTML; handlers do not show up in it. In the browser the page stays live: click the buttons and their lines are added to the output.
Nothing is printed before the snapshot: the handlers run only on a click, never while rendering.
A click on Play Movie prints capture: toolbar, Playing!, bubble: toolbar: down, the button, then up.
A click on Upload Image prints capture: toolbar, Uploading!: Button stops the way up, not the capture handler.
A click on the toolbar between the buttons prints capture: toolbar, bubble: toolbar.
Change it and run it
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.
Your page
Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.
Run the program to see its page here.
Exercises
Exercise 1 of 2
Stop the toolbar from taking the click
The toolbar shows a status line. Clicking Play Movie should show Playing!, and Upload Image should show Uploading!. Run it and click: the status always ends up as You clicked the toolbar, because the click bubbles up to the <div> after the button's handler. Change Button so that it stops propagation and then calls onClick. A click on the toolbar itself, such as on the status line, must still show You clicked the toolbar.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.
Your page
Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.
Run the program to see its page here.
Hints
Hint 1
Button needs its own handler: onClick={e => { … }}. It receives the event object e.
Hint 2
Inside it, call e.stopPropagation(); first, then onClick();.
Hint 3
Leave the toolbar's onClick as it is: clicks on the toolbar itself should still reach it.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.jsx
import { useState } from 'react';
function Button({ onClick, children }) {
// TODO: stop the click from reaching the toolbar, then call onClick.
return <button onClick={onClick}>{children}</button>;
}
export default function Toolbar() {
const [message, setMessage] = useState('Nothing clicked yet');
return (
<div
role="toolbar"
aria-label="Player"
className="toolbar"
onClick={() => setMessage('You clicked the toolbar')}
>
<Button onClick={() => setMessage('Playing!')}>Play Movie</Button>
<Button onClick={() => setMessage('Uploading!')}>Upload Image</Button>
<p role="status">{message}</p>
</div>
);
}
main.test.jsx
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Toolbar from './main.jsx';
const status = () => screen.getByRole('status').textContent;
test('Play Movie shows Playing!', async () => {
const user = userEvent.setup();
render(<Toolbar />);
await user.click(screen.getByRole('button', {name: 'Play Movie'}));
assert.equal(status(), 'Playing!', 'after a click on Play Movie the status reads ' + JSON.stringify(status()));
});
test('Upload Image shows Uploading!', async () => {
const user = userEvent.setup();
render(<Toolbar />);
await user.click(screen.getByRole('button', {name: 'Upload Image'}));
assert.equal(status(), 'Uploading!', 'after a click on Upload Image the status reads ' + JSON.stringify(status()));
});
test('A click on the toolbar itself still reaches the toolbar', async () => {
const user = userEvent.setup();
render(<Toolbar />);
await user.click(screen.getByRole('status'));
assert.equal(status(), 'You clicked the toolbar', 'after a click on the status line it reads ' + JSON.stringify(status()));
});
In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.
Run the program:
npm run dev
There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.
Exercise 2 of 2
A form that does not reload
Signup has a name field and a Send button. Sending a form makes the browser load a new page by default. In jsdom, which the checks use, that prints Not implemented: HTMLFormElement's requestSubmit() method. Complete handleSubmit: call e.preventDefault() on the event, then store the typed name with setSent(name), so the status line shows Submitted: and the name.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.
Your page
Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.
Run the program to see its page here.
Hints
Hint 1
handleSubmit already receives the event object as e.
Hint 2
The first line is e.preventDefault();. It cancels the browser's default, not propagation.
Hint 3
Then call setSent(name);: name holds what was typed, and sent is shown in the status line.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
import { useState } from 'react';
export default function Signup() {
const [name, setName] = useState('');
const [sent, setSent] = useState('');
function handleSubmit(e) {
e.preventDefault();
setSent(name);
}
return (
<form aria-label="Sign up" onSubmit={handleSubmit}>
<label>
Name <input value={name} onChange={e => setName(e.target.value)} />
</label>
<button>Send</button>
<p role="status">{sent ? 'Submitted: ' + sent : 'Nothing sent yet'}</p>
</form>
);
}
Run it on your computer
Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.jsx
import { useState } from 'react';
export default function Signup() {
const [name, setName] = useState('');
const [sent, setSent] = useState('');
function handleSubmit(e) {
// TODO: stop the browser from loading a new page,
// then remember the name that was sent.
}
return (
<form aria-label="Sign up" onSubmit={handleSubmit}>
<label>
Name <input value={name} onChange={e => setName(e.target.value)} />
</label>
<button>Send</button>
<p role="status">{sent ? 'Submitted: ' + sent : 'Nothing sent yet'}</p>
</form>
);
}
main.test.jsx
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen, fireEvent} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Signup from './main.jsx';
const status = () => screen.getByRole('status').textContent;
test('Before sending, the status reads Nothing sent yet', () => {
render(<Signup />);
assert.equal(status(), 'Nothing sent yet', 'the status reads ' + JSON.stringify(status()));
});
test('Sending shows the typed name', async () => {
const user = userEvent.setup();
render(<Signup />);
await user.type(screen.getByRole('textbox', {name: 'Name'}), 'Ada');
await user.click(screen.getByRole('button', {name: 'Send'}));
assert.equal(status(), 'Submitted: Ada', 'after typing Ada and sending, the status reads ' + JSON.stringify(status()));
});
test('The submit handler prevents the default', () => {
render(<Signup />);
const notPrevented = fireEvent.submit(screen.getByRole('form', {name: 'Sign up'}));
assert.equal(notPrevented, false, 'the submit event was not prevented, so the browser would load a new page');
});
In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.
Run the program:
npm run dev
There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.
Common mistakes
A string instead of a function
export default function Save() {
function handleClick() {
console.log('saved');
}
return <button onClick="handleClick()">Save</button>;
}
What React prints
Expected `onClick` listener to be a function, instead got a value of `string` type.
Why, and the fix
In HTML, onclick holds code as text. In React, onClick takes a function, so pass the function in braces: onClick={handleClick}. React warns as soon as the button renders, and the string is never run.
TypeError: Cannot read properties of undefined (reading 'preventDefault')
Why, and the fix
The parentheses call handleSubmit while Signup renders, without an event, so e is undefined and the run stops with Uncaught TypeError (Chrome and Node wording; Firefox and Safari word it differently). Pass the function, onSubmit={handleSubmit}: React calls it on a submit and hands it the event object.
Your own event name on a built-in tag
export default function Player() {
return (
<button onPlayMovie={() => console.log('Playing!')}>
Play Movie
</button>
);
}
What React prints
Unknown event handler property `onPlayMovie`. It will be ignored.
Why, and the fix
You may name the event props of your own components as you like, but a built-in <button> only knows browser events such as onClick. Use onClick on the <button>; give your own Toolbar component a prop named onPlayMovie and pass it on: <button onClick={onPlayMovie}>.
React in the browser: React 19.3.0 (development build) and React Testing Library, MIT, served from this site; JSX is compiled by TypeScript 6.0.3, Apache-2.0. Licence and source
Exit ticket
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.
Key ideas
Pass a function, do not call it
An event handler is your own function that React calls when something happens, such as a click. Define it inside the component, by convention named handle plus the event: handleClick. Pass it with onClick={handleClick}. A short handler can be written inline: onClick={() => console.log('saved')}. Both pass a function. onClick={handleClick()} and onClick={console.log('saved')} call it right away, during every render, not on a click. Because a handler is declared inside the component, it can read the component's props. Unlike rendering, handlers do not need to be pure: they are the best place for side effects.
Handlers travel down as props
Often the parent decides what a click should do. The child accepts a prop and passes it on: function Button({ onClick, children }) renders <button onClick={onClick}>. For your own components you choose the prop names. By convention they start with on and a capital letter, and name what happens in your app: <Toolbar onPlayMovie={…} onUploadImage={…} />. The parent does not need to know how Toolbar triggers them. Built-in tags such as <button> and <div> only know browser event names like onClick; React warns about an unknown one, such as onPlayMovie on a <button>, and ignores it.
Events bubble up; you can stop them
A click on a button inside a <div> runs the button's onClick first, then the div's: the event propagates up the tree. Every event does so in React, except onScroll. A handler receives the event object, usually named e. e.stopPropagation() keeps the event from reaching handlers above. e.preventDefault() is unrelated: it stops the browser's own default, such as a form submit loading a new page. An onClickCapture handler runs on the way down, before any onClick, even when a child stops propagation. Instead of relying on bubbling, a child can call the parent's handler prop itself, which is easier to follow.