β‘ Event Handling in React
A user interface is only interesting when it reacts. A click that adds an item, a keystroke that filters a list, a submit that saves a form β these are events, and events are how your components come alive. In this lesson you'll learn React's way of wiring user actions to code.
Week 4 · Day 3 (Wednesday: Handling Events in React) · Lecture 1
π― Learning Objectives
By the end of this lesson, you will be able to:
- Attach event handlers in JSX using camelCase props like
onClickandonChange - Pass a function reference to a handler and pass arguments without calling it on every render
- Read useful data from the event object (
target,key, modifier keys) - Use
preventDefault()andstopPropagation()deliberately and know the difference - Explain event bubbling and capturing, and handle each phase in React
- Keep handlers efficient with stable references (
useCallback) and delegation
Estimated Time: 60 minutes
Practice: Build a click counter, a keyboard-shortcut box, and a modal that closes on outside clicks.
In This Lesson
Why Events Matter
Think of an event as a doorbell. Someone presses it (a user clicks, types, or scrolls), a wire carries the signal, and a bell rings somewhere inside the house (your code runs). React's job is to make that wiring simple and consistent β you say "when this button is clicked, run this function," and React handles the browser plumbing underneath.
Every interactive feature you'll build β search boxes, dropdowns, drag-and-drop, forms β is an event handler that updates state. And because updating state re-renders the component, the loop from action to UI change is the heartbeat of a React app.
click / type / submit] --> B[React attaches
a SyntheticEvent] B --> C[Your handler runs] C --> D[setState updates data] D --> E[Component re-renders] E --> F[UI reflects the change] F --> A
π A note on SyntheticEvent
The e React hands your handler is not the raw browser event β it's a lightweight cross-browser wrapper called a SyntheticEvent. It behaves the same in Chrome, Firefox, and Safari, which spares you the browser-specific checks developers used to write. The next lesson is a deep dive into that system; today we just use it.
Attaching Handlers
In plain HTML you'd write onclick="doThing()" β a lowercase attribute holding a string. React changes two things: the prop is camelCase (onClick), and you pass an actual function, not a string.
function ButtonExample() {
// Define the handler once, inside the component.
function handleClick() {
alert('Button clicked!');
}
// Pass the function REFERENCE β note: handleClick, not handleClick()
return <button onClick={handleClick}>Click me</button>;
}
β οΈ The most common beginner bug
onClick={handleClick} passes the function so React can call it later, on click. onClick={handleClick()} calls it immediately during render and hands React the return value (usually undefined). The symptom: your handler fires once when the page loads and never again on click.
Inline arrow functions
For short, throwaway logic you can define the handler right in the JSX with an arrow function:
function InlineExample() {
return <button onClick={() => alert('Clicked!')}>Click me</button>;
}
This is fine for tiny handlers. When the logic grows past a line or two, pull it out into a named function β it reads better and is easier to debug.
A real handler that updates state
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
// Using the updater form keeps us correct even with rapid clicks.
const increment = () => setCount(c => c + 1);
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+1</button>
</div>
);
}
This is the pattern behind almost everything: an event handler calls setState, and React re-renders with the new value.
Passing Arguments to Handlers
Often you need to tell the handler which thing was acted on β which item in a list, which tab, which id. Since onClick wants a function reference, you wrap your call in an arrow function so it runs only when clicked:
function ItemList() {
const items = [
{ id: 1, name: 'Apples' },
{ id: 2, name: 'Bread' },
{ id: 3, name: 'Coffee' },
];
const handleSelect = (id) => {
console.log(`Item ${id} selected`);
};
return (
<ul>
{items.map((item) => (
// The arrow defers the call: handleSelect runs on click, not on render.
<li key={item.id} onClick={() => handleSelect(item.id)}>
{item.name}
</li>
))}
</ul>
);
}
π‘ Why the wrapper arrow?
onClick={handleSelect(item.id)} would call the function during render for every list item at once. Wrapping it β onClick={() => handleSelect(item.id)} β hands React a function it can call later, closing over the right item.id.
Getting both the argument and the event
Need the event object and a custom argument? The wrapper arrow receives the event and forwards both:
const handleSelect = (id, e) => {
console.log('Selected', id, 'via', e.type);
};
<li onClick={(e) => handleSelect(item.id, e)}>{item.name}</li>
The Event Object
Every handler receives an event object (conventionally named e or event). It carries details about what happened: which element, which key, whether Shift was held, and more.
function EventInspector() {
const handleClick = (e) => {
console.log('type:', e.type); // "click"
console.log('target:', e.target); // element actually clicked
console.log('currentTarget:', e.currentTarget); // element with the handler
console.log('coords:', e.clientX, e.clientY); // mouse position
console.log('modifiers:', e.shiftKey, e.ctrlKey, e.altKey);
};
return <button onClick={handleClick}>Inspect me</button>;
}
β οΈ target vs currentTarget
e.target is the exact element that triggered the event (maybe a <span> inside a button). e.currentTarget is the element whose handler is running right now. When you attach one handler to a container and clicks come from children, this distinction is what you rely on.
Keyboard events
Keyboard handlers give you the human-readable e.key. Prefer it over the deprecated numeric e.keyCode:
function SearchBox() {
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
console.log('Search submitted');
}
if (e.key === 'Escape') {
e.currentTarget.blur(); // dismiss on Escape
}
};
return <input type="text" onKeyDown={handleKeyDown} placeholder="Searchβ¦" />;
}
Preventing Default Behavior
Browsers have built-in reactions: submitting a form reloads the page, clicking a link navigates away, right-clicking opens a context menu. When your component wants to handle those itself, call e.preventDefault() to cancel the browser's default.
function CommentForm() {
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault(); // stop the full-page reload
console.log('Submitting:', text);
// β¦send to an API, update state, etc.
setText('');
};
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Write a comment"
/>
<button type="submit">Post</button>
</form>
);
}
β The classic use case
Handling onSubmit on the <form> (rather than onClick on the button) is the right pattern β it also fires when the user presses Enter in a field. Just remember the e.preventDefault() or the page will reload and wipe your state.
Propagation: Bubbling & Capturing
When you click a button nested inside a couple of <div>s, the event doesn't only fire on the button. It travels: first down from the document to the target (the capturing phase), then back up from the target to the document (the bubbling phase). Handlers on ancestors fire along the way.
onClick listens in the bubble phase; onClickCapture listens on the way down.Stopping propagation
By default a click on the inner element also triggers handlers on its parents (bubbling). Call e.stopPropagation() to keep the event from travelling further up:
function NestedClicks() {
const handleOuter = () => console.log('Outer clicked');
const handleButton = (e) => {
e.stopPropagation(); // outer handler will NOT fire
console.log('Button clicked β and stopped here');
};
return (
<div onClick={handleOuter} style={{ padding: 20 }}>
Outer area
<button onClick={handleButton}>Click me</button>
</div>
);
}
Listening in the capture phase
Add Capture to any handler name to run it on the way down instead of up:
<div
onClickCapture={() => console.log('Outer capture β fires first')}
onClick={() => console.log('Outer bubble β fires last')}
>
<button onClick={() => console.log('Button β fires in the middle')}>
Click
</button>
</div>
// Order: "Outer capture" β "Button" β "Outer bubble"
π‘ preventDefault β stopPropagation
They solve different problems. preventDefault() cancels the browser's built-in reaction (navigation, form submit). stopPropagation() stops the event travelling to other handlers. Reach for each independently β you rarely need both at once.
Efficient Handlers
Defining a handler inside a component means a brand-new function is created on every render. For most components that's completely fine β function creation is cheap. It only becomes worth optimizing when the handler is passed to many memoized children that would otherwise re-render needlessly.
Stable references with useCallback
import { useState, useCallback, memo } from 'react';
// memo skips re-rendering when props are unchangedβ¦
const ItemButton = memo(function ItemButton({ id, name, onSelect }) {
console.log('render', name);
return <button onClick={() => onSelect(id)}>{name}</button>;
});
function Toolbar() {
const [selected, setSelected] = useState(null);
// β¦so we keep onSelect stable across renders with useCallback.
const handleSelect = useCallback((id) => {
setSelected(id);
}, []);
const items = [
{ id: 'a', name: 'Bold' },
{ id: 'b', name: 'Italic' },
];
return items.map((item) => (
<ItemButton key={item.id} id={item.id} name={item.name} onSelect={handleSelect} />
));
}
β οΈ Don't optimize prematurely
useCallback has a cost too β it adds code and a dependency array to keep correct. Only reach for it when you've paired it with memo and measured a real re-render problem. A plain inline arrow on a button is the right default.
One handler for many children (delegation)
Instead of attaching a handler to every list item, attach one to the parent and read e.target to see what was clicked. Data attributes carry the id:
function TodoList({ todos }) {
const handleClick = (e) => {
const id = e.target.dataset.id; // reads data-id from the clicked <li>
if (id) console.log('Toggled todo', id);
};
return (
<ul onClick={handleClick}>
{todos.map((t) => (
<li key={t.id} data-id={t.id}>{t.text}</li>
))}
</ul>
);
}
React actually uses delegation internally β but doing it explicitly like this can simplify very large or frequently changing lists.
Practice & Quiz
ποΈ Exercise 1: A click counter with a twist
Goal: Build a Counter that increments on a normal click but decrements when Shift is held during the click. Use the event object to detect the modifier.
function Counter() {
const [count, setCount] = useState(0);
const handleClick = (e) => {
// TODO: if e.shiftKey, subtract 1; otherwise add 1
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Click (Shift to subtract)</button>
</div>
);
}
π‘ Hint
The event object has a boolean e.shiftKey. Use the updater form of setCount so rapid clicks stay correct: setCount(c => c + step).
β Solution
const handleClick = (e) => {
const step = e.shiftKey ? -1 : 1;
setCount((c) => c + step);
};
ποΈ Exercise 2: Close-on-outside-click modal
Goal: A modal should close when you click the dark backdrop, but not when you click the white dialog inside it. Use propagation control.
π‘ Hint
Put onClick on the backdrop to close. On the inner dialog, call e.stopPropagation() so its clicks never reach the backdrop.
β Solution
function Modal({ onClose, children }) {
return (
<div className="backdrop" onClick={onClose}>
<div
className="dialog"
onClick={(e) => e.stopPropagation()} // clicks inside stay inside
>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}
π― Quick Quiz
Question 1: Which one correctly wires a handler so it runs on click?
Question 2: You want a form's submit to not reload the page. What do you call?
Question 3: Inside a handler, which property is the element that actually triggered the event?
Best Practices & Pitfalls
β Do
- Pass a function reference (
onClick={handleClick}), or a wrapper arrow when you need arguments - Handle submissions with
onSubmiton the<form>pluse.preventDefault() - Use
e.key(e.g.'Enter','Escape') for keyboard logic, not the deprecatedkeyCode - Name handlers
handleXand the propsonXfor readable, conventional code
β Don't
- Call the handler in JSX:
onClick={handleClick()}fires it once at render, not on click - Reach for
useCallback/memoeverywhere β optimize only measured hotspots - Confuse
stopPropagation()(stops other handlers) withpreventDefault()(stops browser default) - Forget accessibility: make clickable non-buttons keyboard-reachable, or just use a
<button>
β οΈ Stale closures in handlers
// β Reads a possibly stale count
const bad = () => setCount(count + 1);
// β
Updater form always sees the latest value
const good = () => setCount((c) => c + 1);
When a handler updates state based on the current state, use the updater function form. It sidesteps the "why is my counter off by one?" bug caused by closing over an old value.
Summary
π Key Takeaways
- React handlers are camelCase props that take a function reference, not a string or a call
- Wrap in an arrow to pass arguments:
onClick={() => handle(id)} - The event object carries
target,key, coordinates, and modifier flags preventDefault()cancels the browser default;stopPropagation()halts bubbling β they're different tools- Events bubble up (and capture down); listen on the way down with
onXCapture - Optimize handlers with
useCallback+memoonly when a real re-render cost demands it
π Additional Resources
- React docs β Responding to Events
- React docs β The React event object
- MDN β Introduction to events
π What's Next?
You've been using that mysterious e object all lesson. Next we lift the hood: Synthetic Events β how React normalizes browser differences, delegates from the root, and what changed when event pooling was removed in React 17.
π Nicely done!
Your components can now listen and respond. Every interactive UI you build stands on these handler patterns.