π±οΈ React Event Handling System
Every button click, keystroke, and form submit in a React app flows through one clean, cross-browser event system. This lesson shows you how React's SyntheticEvents work, how to write handlers that stay fast, and how events travel through your component tree.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain what a SyntheticEvent is and how React's event system differs from raw DOM events
- Attach handlers for the common mouse, keyboard, and form event types
- Pass arguments to event handlers without breaking performance
- Control event propagation with
stopPropagation, capture-phase handlers, andpreventDefault - Avoid needless re-renders using useCallback and React.memo
Estimated Time: 35β45 minutes β’ Difficulty: Intermediate
Hands-on: Build a keyboard-driven counter with propagation control and a memoized child.
In This Lesson
Events in React
An event is something that happens in the browser that your code can respond to: a click, a key press, a mouse moving, a form being submitted. React lets you respond to these by passing a function to a JSX prop like onClick β no addEventListener, no manual cleanup.
If you've written vanilla JavaScript, React's approach will feel familiar but tidier. Instead of finding an element and attaching a listener, you declare the handler right where the element lives:
function LikeButton() {
const handleClick = () => {
console.log('Liked!');
};
// Pass the FUNCTION, not a call β no parentheses
return <button onClick={handleClick}>π Like</button>;
}
β οΈ Pass a function, don't call it
onClick={handleClick} hands React the function to run later. onClick={handleClick()} calls it immediately during render and passes the return value β a classic bug that fires your handler on every render instead of on click.
Key differences from raw DOM events
- React props use camelCase β
onClick, notonclick;onMouseEnter, notonmouseenter. - You pass a function reference, not a string of code.
- React wraps native events in a SyntheticEvent for consistent cross-browser behavior.
- Returning
falsedoes not prevent default behavior β you must callevent.preventDefault()explicitly.
The SyntheticEvent System
React does not attach a separate listener to every element. Instead, it attaches listeners at the root of your app and uses event delegation internally. When an event fires, React wraps the native event in a SyntheticEvent object and routes it to your handler.
π‘ Analogy: Think of the SyntheticEvent as a universal power adapter. Different browsers historically "used different plugs" for events; React's adapter gives your code one consistent shape to work with, no matter which browser the user is on.
A SyntheticEvent follows the same W3C interface as a native event, so the properties you already know are all there:
function Button() {
const handleClick = (event) => {
console.log('Event type:', event.type); // "click"
console.log('Target element:', event.target); // the <button>
console.log('Native event:', event.nativeEvent); // the raw browser event
};
return <button onClick={handleClick}>Inspect me</button>;
}
π Key Terms
SyntheticEvent: React's cross-browser wrapper around a native DOM event.
nativeEvent: the underlying browser event, reachable via event.nativeEvent when you need something React doesn't surface.
Event delegation: attaching one listener high in the tree that handles events for many descendants β the technique React uses under the hood.
π‘ Event pooling is gone
Older tutorials warn that you must call event.persist() because React "pools" and reuses event objects. That pooling was removed in React 17. On React 18/19 you can safely read event properties inside async callbacks β no persist() needed.
Common Event Types
React exposes the whole family of DOM events as camelCased props. Here are the ones you'll reach for most.
Mouse events
| Prop | Fires when⦠|
|---|---|
onClick | an element is clicked |
onDoubleClick | an element is double-clicked |
onMouseEnter / onMouseLeave | the pointer enters / leaves an element (no bubbling) |
onMouseMove | the pointer moves within an element |
import { useState } from 'react';
function HoverTracker() {
const [pos, setPos] = useState({ x: 0, y: 0 });
const [hovering, setHovering] = useState(false);
const handleMouseMove = (e) => {
setPos({ x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY });
};
return (
<div
onMouseMove={handleMouseMove}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
style={{ width: 300, height: 160, border: '1px solid var(--border-color)' }}
>
<p>Pointer: {pos.x}, {pos.y}</p>
{hovering && <p>Hovering!</p>}
</div>
);
}
Keyboard events
| Prop | Fires when⦠|
|---|---|
onKeyDown | a key is pressed down (use this for shortcuts) |
onKeyUp | a key is released |
β οΈ onKeyPress is deprecated
The old onKeyPress / keypress event is deprecated and doesn't fire for non-character keys. Use onKeyDown and inspect event.key (e.g. 'Enter', 'Escape', 'ArrowLeft').
function NoDigitsInput() {
const [value, setValue] = useState('');
const handleKeyDown = (e) => {
// Block digit keys while still allowing Backspace, arrows, etc.
if (/^\d$/.test(e.key)) {
e.preventDefault();
}
};
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Letters only"
/>
);
}
Form & focus events
| Prop | Fires when⦠|
|---|---|
onChange | an input's value changes (fires on every keystroke in React) |
onSubmit | a form is submitted |
onFocus / onBlur | an element gains / loses focus |
Forms get a full lesson of their own next β here we'll just note that onSubmit handlers almost always start with e.preventDefault() to stop the browser reloading the page.
Writing Event Handlers
Inline vs. named handlers
For a one-liner, an inline arrow function is perfectly fine. Once the logic grows past a line or two, pull it out into a named function so your JSX stays readable:
// Fine for trivial logic
<button onClick={() => setOpen(true)}>Open</button>
// Better once there's real work to do
function Panel() {
const handleOpen = () => {
setOpen(true);
logAnalytics('panel_opened');
focusFirstField();
};
return <button onClick={handleOpen}>Open</button>;
}
Passing arguments to a handler
When you need to pass extra data β like which list item was clicked β wrap the call in an arrow function so it runs on the event, not during render:
function ItemList() {
const items = ['Apple', 'Banana', 'Cherry'];
const handleItemClick = (item, index, event) => {
console.log(`Clicked ${item} at index ${index}`, event.type);
};
return (
<ul>
{items.map((item, index) => (
<li key={item} onClick={(e) => handleItemClick(item, index, e)}>
{item}
</li>
))}
</ul>
);
}
β Use a stable key
Notice key={item} rather than key={index}. When list contents can be reordered or filtered, an index key confuses React's reconciler. Prefer a value that uniquely and stably identifies the row.
Propagation, Capture & Default
Just like native DOM events, React events travel through the tree in phases: they capture downward from the root to the target, then bubble back up from the target to the root.
Capture suffix for the capture phase.Stopping propagation
Call event.stopPropagation() to keep an event from reaching ancestor handlers β handy when a clickable card contains its own clickable button:
function Card({ onOpen }) {
return (
<div onClick={onOpen} style={{ cursor: 'pointer' }}>
<h3>Report</h3>
<button
onClick={(e) => {
e.stopPropagation(); // don't also trigger onOpen
downloadReport();
}}
>
Download
</button>
</div>
);
}
Capture-phase handlers
Append Capture to any event prop to run during the capture phase instead of the bubble phase:
<div
onClickCapture={() => console.log('outer capture (runs first)')}
onClick={() => console.log('outer bubble (runs last)')}
>
<button onClick={() => console.log('button bubble')}>Click</button>
</div>
// Order: outer capture β button bubble β outer bubble
Preventing default behavior
Use event.preventDefault() to cancel the browser's built-in action β submitting a form, following a link, or scrolling on spacebar:
function NewsletterForm() {
const handleSubmit = (e) => {
e.preventDefault(); // stop the full-page reload
console.log('Submitting via fetch instead');
};
return (
<form onSubmit={handleSubmit}>
<input type="email" required />
<button type="submit">Subscribe</button>
</form>
);
}
π‘ Analogy: Bubbling is like an announcement echoing outward through nested rooms.stopPropagation()soundproofs a room so the echo stays inside;preventDefault()is more like telling the building "ignore your usual fire-drill response to that alarm."
Handler Performance
Every render creates fresh inline handler functions. That's usually harmless β but when you pass a handler to a child wrapped in React.memo, a new function each render defeats the memoization and re-renders the child anyway.
The problem and the fix
import { useState, useCallback, memo } from 'react';
// Child only re-renders when its props change by reference
const Child = memo(function Child({ onClick }) {
console.log('Child rendered');
return <button onClick={onClick}>Increment</button>;
});
function Parent() {
const [count, setCount] = useState(0);
// useCallback keeps the SAME function reference across renders
const handleClick = useCallback(() => {
setCount((c) => c + 1); // functional update β no `count` dependency
}, []);
return (
<>
<p>Count: {count}</p>
<Child onClick={handleClick} />
</>
);
}
β Why the empty dependency array works
By calling setCount(c => c + 1) β the functional update form β the handler never needs to read count directly, so it has no dependencies and useCallback can safely memoize it once. The memoized Child then skips re-rendering when only count changes.
β οΈ Don't reach for useCallback everywhere
useCallback has its own cost and adds noise. It pays off when (1) you pass the handler to a memoized child, or (2) the handler is a dependency of another hook like useEffect. For a plain <button onClick={...}> in the same component, an inline arrow is simpler and just as fast.
Hands-on Exercise
ποΈ Build a Keyboard-Aware Counter
Objective: Combine several event concepts into one small component.
Requirements:
- Show a count that starts at
0and can never go below zero. - Provide Increment, Decrement, and Reset buttons.
- Also let the user press ArrowUp / ArrowDown anywhere in the widget to change the count.
- Extract the increment handler with
useCallbackand pass it to a memoized button child.
π‘ Hint
Give the outer <div> tabIndex={0} so it can receive keyboard focus, then add onKeyDown and branch on e.key. For "never below zero," clamp with Math.max(0, c - 1) inside the functional update.
β Sample solution
import { useState, useCallback, memo } from 'react';
const StepButton = memo(function StepButton({ label, onClick }) {
return <button onClick={onClick}>{label}</button>;
});
function Counter() {
const [count, setCount] = useState(0);
const increment = useCallback(() => setCount((c) => c + 1), []);
const decrement = () => setCount((c) => Math.max(0, c - 1));
const reset = () => setCount(0);
const handleKeyDown = (e) => {
if (e.key === 'ArrowUp') { e.preventDefault(); increment(); }
if (e.key === 'ArrowDown') { e.preventDefault(); decrement(); }
};
return (
<div
tabIndex={0}
onKeyDown={handleKeyDown}
style={{ padding: 16, border: '1px solid var(--border-color)' }}
>
<p>Count: {count}</p>
<StepButton label="οΌ" onClick={increment} />
<button onClick={decrement}>β</button>
<button onClick={reset}>Reset</button>
</div>
);
}
π― Quick Quiz
Question 1: What is a React SyntheticEvent?
Question 2: You pass a handler to a React.memo child and it still re-renders every time the parent renders. What most likely fixes it?
Question 3: A button inside a clickable card triggers the card's handler too. How do you stop that?
Best Practices
| β Do | β Avoid |
|---|---|
Pass a function reference: onClick={handleClick} | Calling it during render: onClick={handleClick()} |
Use onKeyDown + event.key | The deprecated onKeyPress |
Call preventDefault() in onSubmit | Relying on a full-page reload to send data |
Reach for useCallback when passing to memoized children | Wrapping every handler in useCallback reflexively |
Use stable, meaningful key values in lists | Array index keys on reorderable lists |
π‘ A note on class components
You may still see class components that bind handlers in the constructor so this works. Function components with hooks are the modern default and sidestep the this problem entirely β that's what we use throughout this course.
Summary & Quiz
π Key Takeaways
- Attach handlers with camelCase props and pass a function reference, not a call.
- React wraps native events in a SyntheticEvent; the raw event is at
event.nativeEvent. Event pooling was removed in React 17. - Prefer
onKeyDownwithevent.key;onKeyPressis deprecated. - Control flow with
stopPropagation(),Capture-suffixed props, andpreventDefault(). - Use
useCallback+React.memoto avoid needless child re-renders β but only where it actually pays off.
π Further Reading
- React β Responding to Events
- React β Common component props & events
- MDN β Introduction to events
π What's Next?
Now that you can respond to any interaction, the next lesson focuses on the interaction developers handle most: forms. We'll manage input state, wire up submission, and validate what users type.
π Well handled!
You can now make React apps respond to clicks, keys, and everything in between β and keep them fast doing it.