Lesson 53·M6 React Fundamentals
Events & Synthetic Events
OpenLearning Objectives
By the end of this lesson you should be able to:
- Explain what a synthetic event is and why React bothers wrapping the native one
- Explain why React attaches all event listeners at the root, and where exactly
- Say what event pooling was, why it existed, and that it's gone since React 17
- Explain how
stopPropagationandpreventDefaultbehave — and thenativeEventcaveat - Handle form submit correctly, and know what React's
onChangereally fires on
What is a Synthetic Event?
A synthetic event is React's cross-browser wrapper around the browser's native event — same properties and methods you expect, normalised so behaviour is consistent everywhere.
<button onClick={(e) => console.log(e.type)}>Click</button>
e.type → "click"
e.target → the DOM element that was clicked
e.currentTarget → the DOM element the handler is attached to (handler owner)
e.nativeEvent → the underlying native event
e.stopPropagation / e.preventDefault / e.stopImmediatePropagation
onClick and friends are not DOM attributes. They are props, compiled like any other JSX,
and the handler receives the synthetic event.
Mental Model
A conference call. The browser has one open line — a single listener at the root of the tree. Every event travels up that line. When an event reaches the root, React looks at its call log, finds which components registered handlers along the way, and calls them in order.
The synthetic event is the translated transcript everyone gets. You don't speak "the
browser's dialect" — React has already normalised it, so the transcript reads the same on
every browser. The nativeEvent is the raw recording underneath.
Visual Flow
user clicks <button>
│
▼
native event bubbles: button → div → ... → #root (React 17+)
│
▼
React's single listener at the root catches it
│
▼
React maps the event to SyntheticEvent, dispatches it
through the fiber tree: deepest target first, then up
(stopPropagation cuts this walk short)
│
▼
your handler runs → return value ignored → synthetic
event is released (or just discarded)How It Works
React doesn't put a listener on each element. It attaches one listener per event type at the root and relies on native bubbling to bring events to it:
- React ≤16 attached to the
document. - React 17+ attaches to the root container (the
#rootdiv you render into).
Why does the change matter? Interop: with the listener on document, a React app inside a
page made of other frameworks/scripts was fighting them over document-level events —
stopPropagation inside React couldn't stop a native listener at document (it ran
before React's), and a native stopPropagation at document could never be called before
React's listener. Moving to the root container gives each app its own event territory, and
e.stopPropagation() inside React now behaves like the native one for the whole page.
From the captured event, React replays the path: it walks its fiber tree from the target up
to the root, invoking your onClick handlers — mirroring native bubbling, so your handler
gets a currentTarget that reflects the element the handler is on. In delegation terms,
React is one delegated listener per event type; currentTarget is computed, not attached.
Tip
One listener per event type, not one per handler. That's the whole trick — thousands of buttons cost one listener each for click, change, input, etc.
Why the Wrapper? — Normalisation
The same event behaves differently across browsers: key vs keyCode vs which,
charCode quirks, target vs srcElement, button numbering. The synthetic event maps
all of that to one consistent API. Your handler code stops caring which browser it runs in:
function KeyLogger() {
const [key, setKey] = useState('');
return (
<input
value={key}
onKeyDown={(e) => setKey(e.key)} // same e.key everywhere — no keyCode branches
placeholder="type something"
/>
);
}
user presses "Enter" → e.key === "Enter" on every browser
(no keyCode === 13 / which === 13 branching)The Form Case: onSubmit and the "onChange" Surprise
onSubmit fires on the <form> when it's submitted — by Enter in a field, or by a submit
button. That's the event to intercept:
function Search() {
const [q, setQ] = useState('');
const handleSubmit = (e) => {
e.preventDefault(); // stop the native navigation/reload
doSearch(q);
};
return (
<form onSubmit={handleSubmit}>
<input value={q} onChange={(e) => setQ(e.target.value)} />
<button type="submit">Search</button>
</form>
);
}
Enter in the input → onSubmit fires → preventDefault stops the page reload → doSearch runs
Pitfall
React's onChange is not the DOM change event — it fires on every keystroke (it maps
to the native input event). That's the behaviour every controlled component from
Lesson 54 depends on. The DOM change event fires only when the field loses focus.
stopPropagation and preventDefault
Both are called on the synthetic event, and both work — with one asymmetry you should know:
function StopCase() {
return (
<div onClick={() => console.log('outer div')}>
<button
onClick={(e) => {
e.stopPropagation(); // ✅ stops React handlers above (and native bubbling)
console.log('button');
}}
>
click me
</button>
</div>
);
}
button
stopPropagation stops the synthetic walk upward — the outer div's onClick never runs —
and stops the native event's bubbling, so native listeners above the root never see it
either (React 17+).
function PreventCase() {
return (
<a href="/page" onClick={(e) => e.preventDefault()}>
won't navigate
</a>
);
}
link renders, click does nothing — no navigation
preventDefault doesn't stop propagation — handlers further up still run; the default
browser action (navigation, reload, checkbox toggle) is just cancelled.
Pitfall
The one place the symmetry breaks: if you call e.stopPropagation() inside a native
(non-React) handler, React 17+ won't receive the event at all. That's not a React bug —
it's the direct consequence of the single-root-listener design. And since React 17 the
native event itself isn't modified: React stores the handler calls on a parallel
bookkeeping structure, so a native listener at the root can see and stop propagation
that React already processed.
Event Pooling (Historical, Gone Since React 17)
Before React 17, React reused event objects through a pool: after the handler ran, the synthetic event was "released", its properties nulled, so a new event could reuse the same object. The consequence was a classic interview question:
function Pooled() {
const [msg, setMsg] = useState('');
return (
<button
onClick={(e) => {
setTimeout(() => setMsg(e.target.textContent), 100); // ❌ e is nulled by then
}}
>
click me
</button>
);
}
before React 17: e.target is null by the time the timeout runs → crash / undefined
React 17+: e.target still readable — pooling is gone
Fix under pooling (this is why the pattern exists — keep it as a habit anyway):
const text = e.target.textContent; // ✅ copy what you need, synchronously
setTimeout(() => setMsg(text), 100);
Since React 17 the synthetic event is a plain object created per event — it stays alive, is
safe to read in callbacks, and e.persist() (the old escape hatch) is a no-op. The question
is now historical: interviewers still ask it to check whether you know modern React.
The Interview Explanation
React wraps every native event in a synthetic event — a normalised, cross-browser object with the usual properties and methods. Instead of attaching a listener to every element, React attaches one listener per event type at the root of the tree — the root container since React 17 — and lets native bubbling carry events to it, then dispatches them back down through the fiber tree to your handlers. That single delegation point is why
stopPropagationandpreventDefaultbehave the way they do, and it's the reason event pooling existed before React 17 and was removed.
Senior-Level Insights
- Event delegation is the design constraint. Everything — the root listener, the stopPropagation semantics, pooling, interop — follows from "one listener per event type at the root". Explain the constraint and all three classic answers fall out of it.
currentTargetis computed, not attached. Because delegation replays the path, the syntheticcurrentTargetis set correctly per handler — which is not true of naive delegation wherecurrentTargetis always the delegated element.onChangeon controlled inputs is the foundation (Lesson 54 builds directly on it). Knowing it maps to the nativeinputevent is a small fact with large consequences.- Events are per-render values. The handler prop is part of the element from Lesson 51: a new function every render, with that render's state captured in a closure (Lesson 5). That's what makes stale handlers and re-subscription questions work.
- Passive listener trade-offs: for scroll/touch performance you can opt into native passive listeners, but the delegation design means React can't be passive by default.
Common Mistakes
- Assuming
onChange= DOMchange. React's fires on every keystroke — that's theinputevent. "My handler doesn't fire until I blur" is the DOM-event symptom, and it's a wrong-mental-model bug. - Calling
preventDefaultto stop other handlers. It cancels the default action only. To stop handlers above, you needstopPropagation— they're different tools. - Reading the synthetic event in an async callback and expecting the old bugs. On React 17+ it's safe, and old advice to "copy values synchronously" persists from pooling. Still good practice, no longer required.
- Trying to add a listener to the wrong target.
document.addEventListener('click', …)in an effect without cleanup doubles up in Strict Mode — and leaks. Clean up, or attach to the ref. - Relying on
e.persist(). It's a no-op since React 17 — and a red flag in an interview if you reach for it as a "fix".
Best Practices
✅ Use onSubmit on the form and preventDefault() — never onClick on a submit button
✅ Copy any event data you need synchronously — still the clearest code, and it was the pooling rule before React 17
✅ Put stopPropagation only where the parent genuinely must not react — not "just in case"
✅ Prefer e.key for keyboard handling; it's the normalised property, no keyCode branches
✅ For delegation needs of your own, attach a single listener to a parent instead of one per child
❌ Don't rely on e.persist(), e.nativeEvent internals, or pooling-era workarounds — all
historical since React 17
❌ Don't add and forget document listeners without cleanup
❌ Don't use preventDefault when you mean stopPropagation
Interview Questions
Q1. What is a synthetic event?
React's cross-browser wrapper around the native browser event. It exposes the standard event API — type, target, currentTarget, preventDefault, stopPropagation — with normalised behaviour, so handlers don't need browser branches.
e.nativeEventis the underlying native event.
Q2. Why does React attach event listeners at the root?
Event delegation: instead of a listener on every element, React attaches one listener per event type at the root and relies on native bubbling. Events arrive there, React replays the component path, and calls the handlers in order. One delegated listener per event type means thousands of buttons cost almost nothing to wire up.
Q3. Where does React attach listeners, and why does the version matter?
React 17 moved from
documentto the root container. Ondocument, React was fighting other frameworks and native listeners over document-level events: a native listener atdocumentran before React's, and React'sstopPropagationcouldn't stop it. On the root container, each app owns its event territory, andstopPropagationinside React now behaves like the native one for the whole page.
Q4. What was event pooling, and why was it removed?
Before React 17, synthetic events were reused from a pool — after the handler ran the object was nulled and recycled, so reading it asynchronously returned nulled properties, and you had to copy values synchronously. React 17 removed pooling entirely: each event is a plain object that stays valid, and
e.persist()is now a no-op. It's a historical question today — asked to see whether you know modern React.
Q5. How does stopPropagation behave in React?
Called on the synthetic event, it stops React's dispatch up the fiber tree — parent handlers don't run — and, since React 17, it also stops the native event's bubbling, so native listeners above the root don't see it either. The old pre-17 asymmetry — native listeners at
documentstill firing — is gone.
Q6. What is the difference between stopPropagation and preventDefault in React?
preventDefaultcancels the browser's default action — navigation, reload, checkbox toggle — while the event keeps propagating and other handlers still run.stopPropagationstops the event travelling further — handlers above never run — but the default action still happens. In forms you usually need both ideas: preventDefault on submit, stopPropagation when a parent must not react.
Senior follow-up: A page with a React app and a jQuery widget — how do their event listeners interact?
If React is on 17+, its listeners are on the React root container, so both systems own their own event territory: each listens where it attaches. On React ≤16 the listeners were on
document, so React and jQuery fought over document-level events — a jQuerystopPropagationatdocumentcould silence React, and React's ownstopPropagationcouldn't stop a nativedocumentlistener that ran first. That interop problem was a large part of why React 17 moved to the root container.
Follow-up Questions
Why is React's onChange different from the DOM change event?
React maps
onChangeto the nativeinputevent, which fires on every keystroke, not on blur. Controlled components (Lesson 54) depend on this — state updates as you type, and the input's value is always whatever state says.
What exactly does e.nativeEvent give you?
The browser's original event object — same event, before React's wrapper. You can read browser-specific details there. React's synthetic properties are usually sufficient, and touching
nativeEventis the escape hatch for the rare case they aren't.
What happens to the return value of an event handler?
Nothing. React ignores it — returning
falsefrom a handler does not prevent default, unlike classic inlineonclick="return false". If you want the default prevented, callpreventDefault().
Why does React re-create the handler every render?
Because the handler prop is part of the element, and every render builds a new element (Lesson 51). The handler closes over that render's props and state (Lesson 5) — which is what makes stale handlers a thing, and
useCallback(Lesson 62) the stabilising fix.
Comparison Table
| Native DOM | React synthetic | |
|---|---|---|
| Event object | Event, browser-specific | SyntheticEvent wrapper, normalised |
| Listener placement | On the element (or wherever you attach) | One per type, at the root container |
stopPropagation | Stops native bubbling | Stops React dispatch + native bubbling (17+) |
preventDefault | Cancels default action | Same — but does NOT stop propagation |
return false | Prevents default (inline) | Ignored — must call preventDefault() |
| Async reads of the event | Safe | Safe since 17 (pooled before) |
onChange semantics | Fires on blur | Fires on every keystroke (input event) |
Performance Notes
- Delegation scales. One listener per event type at the root — thousands of handlers cost one listener each. The cost moves to dispatch, which is O(depth) per event.
- Handlers are created per render (new closure per render — Lesson 5/51). For hot
paths,
useCallback(Lesson 62) stabilises the reference; the listener count at the root doesn't change either way. - Passive events: React doesn't attach passive listeners, so scroll/touch handlers can block scrolling if they're slow. For high-frequency native listeners, attach your own passive listener to a ref — and remember the root-delegation caveat.
- Strict Mode double-invokes event-handler creation, not the handlers themselves — handlers run once per event.
Debugging Scenarios
Scenario 1: "My onChange fires only when the field loses focus."
You're expecting React semantics from a native listener: you attached the handler to the
DOM node (addEventListener('change', …) or a native onchange attribute) instead of
using React's onChange prop. React's onChange maps to the input event.
Scenario 2: "A parent's onClick fires even though I called stopPropagation."
You probably called it on the wrong event, or the parent listener is native — a document
listener attached in an effect runs in native phase and, since React 17, sits outside
React's root container. If the parent is a React handler, check you called
e.stopPropagation() (not e.preventDefault()) and on the synthetic event, not
nativeEvent.
Scenario 3: "My form reloads the page on submit."
Missing e.preventDefault() in onSubmit — or the submit went out via a button without
type (defaults to submit) while the handler was attached elsewhere. Handle onSubmit
on the <form> and preventDefault there.
Scenario 4: "A handler reads stale state."
The handler is a closure from the render that created it (Lesson 5). If it's an async or
long-lived callback, capture the latest state with a ref, or rebuild the handler with
useCallback deps — same fix as any stale closure.
Quick Revision Notes
onClicketc. are props, not DOM attributes — handlers receive aSyntheticEvent- One listener per event type at the root container (React 17+;
documentbefore) - Root delegation is why stopPropagation semantics and pooling history are what they are
preventDefault≠stopPropagation— cancel action vs stop propagationreturn falsein a handler does nothing — callpreventDefault()- Event pooling: historical, removed in React 17,
e.persist()is a no-op - React
onChangefires per keystroke (nativeinputevent), not on blur - Async reads of the event are safe on React 17+
- Handlers are new closures per render — stale-state territory (Lessons 5, 62)
Cheat Sheet
<button onClick={(e) => ...}>
│
└─ SyntheticEvent
.type event name, normalised
.target DOM node the event happened on
.currentTarget DOM node of the handler's owner
.nativeEvent raw browser event
.stopPropagation() stop React dispatch + native bubbling (17+)
.preventDefault() cancel default action (still propagates)
.persist() no-op since React 17
<Form onSubmit={e => { e.preventDefault(); save(); }}> // the form patternKey Takeaways
Recap
- Synthetic events are React's normalised wrapper around native browser events
- React attaches one listener per event type at the root container (17+), not on each element
- Delegation at the root explains stopPropagation semantics, interop, and pooling history
preventDefaultcancels the default action;stopPropagationstops handler dispatch — different toolsreturn falseis ignored in React handlers — always callpreventDefault()- Event pooling is gone since React 17; async reads are safe;
e.persist()is a no-op - React's
onChangefires on every keystroke — the foundation of controlled components in Lesson 54
Check your understanding
Answer each one out loud. Then rate yourself honestly.
0/8What is a synthetic event, and what is e.nativeEvent?
Why does React use a single root listener instead of per-element listeners?
Where were listeners attached before React 17, and what problem did the move solve?
What was event pooling, and what did you have to do because of it?
stopPropagation versus preventDefault — what does each do, and do they affect each other?
A handler returns false — what happens?
Why does React's onChange fire on every keystroke when the DOM's change fires on blur?
Why is the handler prop a new function every render, and which two earlier lessons explain it?
What's Next
Lesson 54 — Controlled vs Uncontrolled Forms. You now know the event that fires on every
keystroke, the onSubmit pattern, and the closure that carries each render's state. Lesson
54 assembles those into React's most direct real-world question: who owns the input's value
— React, or the DOM?