Module 3: State and Interactivity
Event Handling
How React events work, how bubbling and default behavior play out, and the handler mistakes that run code at the wrong time.
Handling events in React is mostly what you already know from the DOM, with a few differences in spelling and one big difference in where things run. If you've used addEventListener, you'll feel at home within ten minutes. The details are what separate "it works" from "it works and I understand why".
Attaching a handler#
function SaveButton() {
function handleClick() {
alert("Saved");
}
return <button onClick={handleClick}>Save</button>;
}
Three rules. The prop is camelCase (onClick, onChange, onSubmit, onKeyDown). You pass a function, not the result of calling one. And the convention for naming the function is handleSomething, while props that accept handlers are named onSomething.
The most common beginner error is the second rule:
<button onClick={handleClick()}>Save</button> // runs during render, passes undefined
<button onClick={handleClick}>Save</button> // passes the function itself
<button onClick={() => handleClick()}>Save</button> // also fine, wraps it
With the parentheses, handleClick runs the moment the component renders, not when the user clicks. If that handler calls setState, you get an infinite render loop.
Passing arguments#
Wrap the call in an arrow function:
function List({ items, onRemove }) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>
{item.name}
<button onClick={() => onRemove(item.id)}>Remove</button>
</li>
))}
</ul>
);
}
That arrow is created per render, per item, and closes over item.id. It's totally fine, even with hundreds of items. Don't optimize it until you've measured a problem.
The event object#
React hands your handler a "synthetic event", a wrapper that normalizes browser differences and mirrors the native event API.
function SearchBox() {
const [query, setQuery] = useState("");
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") console.log("search for", query);
}}
/>
);
}
Useful bits: e.target is the element that triggered the event (could be a child), e.currentTarget is the element your handler is attached to, e.key identifies keyboard keys, and e.preventDefault() cancels the browser's default behavior.
Old tutorials mention "event pooling" where you had to call e.persist() before using an event asynchronously. That was removed in React 17. You can read the event after an await without trouble.
Default behavior and forms#
Browsers have default actions, and submitting a form reloads the page. In a single-page app, that's almost never what you want.
function Login() {
function handleSubmit(e) {
e.preventDefault(); // stop the page reload
// validate and send data here
}
return (
<form onSubmit={handleSubmit}>
<input name="email" />
<button type="submit">Sign in</button>
</form>
);
}
Listen on onSubmit on the form, not onClick on the button. That way pressing Enter inside a field also works, and screen readers behave properly.
Bubbling#
Most events bubble: they start at the element you clicked and travel up through each ancestor.
click on <button>
│
▼
<button onClick> ← handler runs first
│ bubbles up
▼
<div onClick> ← then this one
│
▼
<section onClick> ← then this one
<div onClick={() => console.log("card")}>
<button onClick={(e) => { e.stopPropagation(); console.log("button"); }}>
Delete
</button>
</div>
Without stopPropagation(), clicking the button logs both "button" and "card". That's useful when you want a clickable card with buttons inside that shouldn't also open the card. Use stopPropagation sparingly though. Once you start stopping events, other parts of your app that listen for them (like a click-outside detector) can stop working in confusing ways.
There's also a capture phase, where the event travels down before it bubbles up. You can listen to it with onClickCapture. You'll rarely need it.
Where React puts the real listener#
React doesn't attach a listener to every button. It attaches one listener per event type on the root container (the #root div) and figures out which of your handlers should run based on where the event came from. This is event delegation, and it keeps memory low even in huge lists.
One consequence: if you mix React handlers with native listeners that you add on document yourself, the order and stopPropagation interplay can surprise you. React handlers run when the event reaches the root, not before.
Event handlers can have side effects#
Rendering must be pure. Event handlers are exactly where impure things belong: sending requests, saving to storage, navigating, updating state. If something should happen because the user did something, put it in the handler. Reaching for useEffect for that is a very common mistake, and we'll revisit it in the effects lesson.
Controlled input pattern#
You'll write this one constantly:
function NameField() {
const [name, setName] = useState("");
return (
<>
<input value={name} onChange={(e) => setName(e.target.value)} />
<p>Hello, {name || "stranger"}</p>
</>
);
}
Every keystroke fires onChange, which sets state, which re-renders with the new value, which puts it back in the input. We'll look at this loop closely in the forms lesson.
Where people get burned#
onClick={handle()}instead ofonClick={handle}.- Forgetting
e.preventDefault()on a form and watching the page refresh. - Using
onClickon a div for something interactive. Abuttongives you keyboard support and accessibility for free. - Confusing
e.targetande.currentTargetwhen children are involved. - Using
onChangeand expecting it to fire on blur. In React,onChangeon text inputs fires on every keystroke, like the nativeinputevent. - Calling
stopPropagationeverywhere because one click handler "wasn't working", when the real issue was elsewhere.
Practice#
- Make a button that increments a counter and a second button that resets it. Put both inside a parent div that logs "parent clicked", then stop the bubbling on only the reset button.
- Build a search input that logs the query only when the user presses Enter, and clears it when they press Escape.
- Create a list with a delete button for each row, using a handler that takes the item's id.
- Wire up a small form with
onSubmit, prevent the default, and show the submitted values below it.