Synthetic events
How React's synthetic event system wraps and types the native DOM events you handle in a component.
In “The event model” you learned the native event system by hand: addEventListener, the capture and bubble phases, event.target versus event.currentTarget, preventDefault and stopPropagation, and delegation.
Now JSX hands you onClick, onChange, and onSubmit as props on an element. They look like a brand-new event system, but they aren’t, and assuming they are is where people go wrong. onClick is the native model you already know, wrapped and typed. React calls the wrapper a synthetic event , and almost all of it behaves like the native event. This lesson is the translation table: what carries over (most of it), what you type by hand, and three behaviors that cause real production bugs because the names match native but the semantics don’t.
The SyntheticEvent wrapper
Section titled “The SyntheticEvent wrapper”Start with the smallest handler there is, a button that runs code when you click it.
import type { MouseEvent } from 'react';
export function SaveButton() { const handleSave = (e: MouseEvent<HTMLButtonElement>) => { e.preventDefault(); e.currentTarget.disabled = true; console.log(e.nativeEvent); };
return <button type="button" onClick={handleSave}>Save</button>;}When the button is clicked, React calls handleSave with one argument, the event, named e by convention. Log it and you’ll see the same shape you inspected on a native event in DevTools: type, target, currentTarget, preventDefault(), stopPropagation(). But it isn’t a native event. It’s a SyntheticEvent, React’s wrapper around the underlying browser Event.
The surface is the one you already learned natively. e.preventDefault() still cancels the browser’s default action, the form submit or link navigation. e.stopPropagation() still halts the event from travelling further up the tree. The event still bubbles : a click on a child fires its onClick, then its parent’s, then its grandparent’s, exactly as the DOM does. It’s a thin layer on the event model you already know.
When you need the raw browser event, e.nativeEvent is the native Event underneath. You’ll reach for it rarely, mostly to call something with no synthetic equivalent, like e.nativeEvent.stopImmediatePropagation().
So why wrap the event at all? Originally the layer normalized browser quirks so one handler worked everywhere. Those quirks have mostly evaporated, but the wrapper stayed, because normalization was never its only job. Dispatching through React’s own tree is what lets React batch your handler’s work: every setState you fire inside one handler collapses into the single re-render you saw earlier in this chapter. The wrapper is the seam where the event system and the render system meet.
Typing event handlers
Section titled “Typing event handlers”When you extract a handler to a named function, what type does the event parameter take? React’s event types are generic over the element the handler is attached to: you import them from react and parameterize them with the element type.
import type { ChangeEvent, FormEvent, KeyboardEvent, MouseEvent } from 'react';
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {};const handleChange = (e: ChangeEvent<HTMLInputElement>) => {};const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {};const handleSubmit = (e: FormEvent<HTMLFormElement>) => {};These four cover most of what you’ll write. The rest of the family, FocusEvent, PointerEvent, ClipboardEvent, DragEvent, works the same way: import from react, parameterize with the element.
Most of the time you can skip the annotation entirely. An inline handler infers its parameter type from its position in the JSX. Write <button onClick={(e) => ...}> and TypeScript reads the type off the onClick prop and the <button> it sits on, so e is MouseEvent<HTMLButtonElement>. You annotate only when you extract the handler to a named const, because then there’s no prop position left to infer from.
<button type="button" name="save" onClick={(e) => console.log(e.currentTarget.name)}> Save</button>No annotation needed. TypeScript infers e as MouseEvent<HTMLButtonElement> from the onClick prop, and e.currentTarget is typed as the button. Use this for any handler short enough to read inline.
import type { MouseEvent } from 'react';
const handleSave = (e: MouseEvent<HTMLButtonElement>) => { console.log(e.currentTarget.name);};
<button type="button" name="save" onClick={handleSave}>Save</button>You annotate the parameter, since a named const has no prop position to infer from. Note the import type: event types are type-only, and verbatimModuleSyntax requires the type keyword.
Importing MouseEvent from react shadows the global DOM MouseEvent in that file. That’s what you want inside a React component: React’s typed version, not the raw DOM one.
Pick the event type for each handler:
Pick the event type for each extracted handler. The element each one is bound to is named in the comment. Pick the right option from each dropdown, then press Check.
// onChange on the <input> search boxconst handleSearch = (e: ___) => setQuery(e.currentTarget.value);
// onClick on the "Add" <button>const handleAdd = (e: ___) => addItem();
// onSubmit on the <form>const handleSend = (e: ___) => { e.preventDefault(); submit();};Reach for currentTarget, not target
Section titled “Reach for currentTarget, not target”The distinction between these two is the same as in native DOM, but in React it shows up as a TypeScript error rather than a runtime surprise. currentTarget is the element the handler is attached to; target is the deepest element the event originated on.
The catch is in the types. The handler’s prop position pins down which element it sits on, so e.currentTarget is typed as that element: on an input handler, e.currentTarget.value is fully typed, no cast. But e.target is typed as the generic EventTarget, which has no .value, .checked, or .name. Writing e.target.value fails to compile.
const handleChange = (e: ChangeEvent<HTMLInputElement>) => { setEmail(e.target.value);};TypeScript error: Property 'value' does not exist on type 'EventTarget'. The event could have originated on any descendant, so React can’t promise target is the input.
const handleChange = (e: ChangeEvent<HTMLInputElement>) => { setEmail(e.currentTarget.value);};Clean and typed. currentTarget is pinned to the HTMLInputElement the handler sits on, so .value is right there. Make this your default for reading .value, .checked, .name, and dataset off the event.
The two point at the same node whenever the handler sits directly on the element you interacted with, which is the everyday case. They diverge only under delegation, where the handler is on a parent and you clicked a child. That is the one time you’d deliberately read target and narrow it, since you know which element to expect. For reading a control’s own value, default to currentTarget.
Delegation happens at the React root
Section titled “Delegation happens at the React root”You already know delegation from “The event model”: one listener on an ancestor handles many descendants. The new fact is where React puts that listener, and it drives a stopPropagation surprise that stale tutorials still get wrong.
You might picture each onClick prop becoming a native addEventListener call on that button. It doesn’t work that way. Since React 17, React attaches one listener per event type at the root container, the single DOM node it mounts your tree into. Your onClick props are entries in React’s internal tree, not native listeners on each button. When you click, the click bubbles up the real DOM to that one root listener; React reads where it landed and dispatches a synthetic event back down through your component tree, firing the matching onClick handlers along the way.
Where the listener lives has two consequences, and the second causes bugs.
The first is harmless: multiple independent React trees on one page don’t fight. Before React 17 the single listener sat on document, so two separately-mounted apps stomped on each other’s document-level handling. Now each root owns its listener at its own container, and they coexist. You’ll rarely run two roots in a Next.js app, but this is why the listener moved.
The second is the one to remember: e.stopPropagation() in a React handler now reaches farther than it used to. Because React’s listener sits at the root container, not at document, a React handler that calls e.stopPropagation() stops the native event at the root, so a native listener you attached to document (or any ancestor above the root) never fires. This is the exact opposite of pre-React-17, when React listened at document itself: by the time your handler ran, the native document listener had already received the event, and stopPropagation couldn’t un-fire it. People wrote that down, and the note outlived the behavior.
So the durable rule is that a React child’s stopPropagation() contains the event within the root container. A component deep in your tree can silently suppress a global native document listener, such as a keyboard-shortcut handler or a third-party DOM library listening above the root. Native listeners below the root still interleave with React handlers by ordinary DOM bubbling, so nothing strange happens there.
document native click listener can be suppressed <div id="root"> React's ONE click listener one per event type <form> <button> ⊕ clicked
<App> <SignupForm> <SubmitButton> onClick runs To make sure the surprise landed:
A global shortcut listener is wired up on document, and a button deep inside the React tree calls stopPropagation in its onClick:
// Registered once on mount:document.addEventListener('click', () => console.log('document saw a click'));
// Rendered deep in the React tree:<button type="button" onClick={(e) => e.stopPropagation()}>Mute</button>You click Mute. On React 17 and later, does document saw a click print to the console?
document, so that listener never runs.stopPropagation inside a React handler can only silence other React handlers, not a native listener on document.stopPropagation only cancels the capture phase, and the document listener is a bubble-phase listener.click listener attached.document. A React child’s stopPropagation halts the native event at that root, so it never reaches an ancestor listener on document — and the log never fires. The “yes” answers describe the pre-React-17 model, where React listened at document itself and couldn’t un-fire it; “it depends” is a red herring.onChange fires on every keystroke
Section titled “onChange fires on every keystroke”The native change event, as documented on MDN, fires only when an input loses focus after its value changed: you type, you click away, then it fires.
React’s onChange does not work that way. onChange on a text input fires on every keystroke. Type five characters and it fires five times. React wires onChange to the native input event, not the native change event, so the name is borrowed but the behavior is the native input event’s.
That per-keystroke firing is what makes live-updating UI work. The canonical pattern pushes each keystroke straight into state:
<input value={query} onChange={(e) => setQuery(e.currentTarget.value)} />Every keystroke fires onChange, which calls setQuery, which re-renders with the new value, so the UI tracks the input in real time.
Mirroring that value back into the input, as above, makes it a controlled input; letting the DOM hold it makes it uncontrolled. That design choice belongs to forms, which the course covers later.
Handling form submit with preventDefault
Section titled “Handling form submit with preventDefault”A <form> submits by default to its action URL, reloading the whole page. To handle the submit in JavaScript instead, cancel that default with the same call you learned natively:
<form onSubmit={(e) => { e.preventDefault(); const data = new FormData(e.currentTarget); saveDraft(Object.fromEntries(data)); }}> {/* fields */}</form>Without preventDefault , the page reloads and your handler’s work is lost. Extracted, the handler’s type is FormEvent<HTMLFormElement>.
You’ll write this less than older code does: the modern React form uses the <form action={fn}> prop, where the function owns the whole submit lifecycle, preventDefault included. Forms get their own unit later; for now, recognize the onSubmit plus preventDefault shape when you see it.
Keyboard events and e.key
Section titled “Keyboard events and e.key”Keyboard handling is a small family the rest of the course leans on, so learn the one property that matters.
Read e.key. It’s a string naming the key: 'Enter', 'Escape', 'ArrowUp', ' ' for the spacebar, 'a' for the A key. The older e.keyCode, e.charCode, and e.which are deprecated. There’s also e.code, which names the physical key regardless of layout and is handy for game controls, but e.key is the one you’ll use day to day.
The canonical use is dismissing things on Escape:
<input onKeyDown={(e) => { if (e.key === 'Escape') { clearSearch(); } }}/>Escape-to-close on modals and menus is the everyday reach, and reading the key is all it takes. Where focus goes when an overlay closes, and trapping Tab inside it, is a separate topic the course handles later with shadcn’s overlay components.
For chord shortcuts like ⌘K, the modifiers are booleans on the event: e.metaKey (⌘ on macOS, Windows key elsewhere), e.ctrlKey, e.shiftKey, e.altKey. Check e.key === 'k' && e.metaKey and you’ve got the command palette trigger.
One naming choice: reach for onKeyDown, not the deprecated onKeyPress. onKeyDown fires before the value changes and covers every key, including modifiers, arrows, and Escape; onKeyPress only ever fired for printable characters.
Pointer events: one API for mouse, touch, and pen
Section titled “Pointer events: one API for mouse, touch, and pen”When code must handle both mouse and touch, such as a drag handle or a custom slider, reach for pointer events: onPointerDown, onPointerMove, and onPointerUp cover mouse, touch, and pen through a single API. To branch on the input type, read e.pointerType, which is 'mouse', 'touch', or 'pen'. The legacy approach wires up onMouseDown and onTouchStart separately and then reconciles the two, handling every interaction twice.
<div onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); const slop = e.pointerType === 'touch' ? 12 : 4; startDrag({ x: e.clientX, y: e.clientY, slop }); }}> {/* draggable handle */}</div>The detail worth knowing is the setPointerCapture call. Pointer capture keeps the browser sending pointer events to this element even after the pointer moves outside it, which is exactly what a drag needs: your cursor passes the handle’s edges the moment you start dragging. Without it, the drag drops the instant you leave the element.
A full drag-and-drop system, such as reordering a list or dragging cards between columns, is what libraries like @dnd-kit exist for. The default to remember: for anything cross-input, reach for onPointer*, not onMouse* plus onTouch*.
Three more synthetic-event facts
Section titled “Three more synthetic-event facts”Three smaller behaviors you’ll hit before long.
The capture-phase variant. Every handler so far has been bubble-phase: onClick fires as the event travels up the tree. The capture-phase version fires on the way down, before children see the event, and appends Capture to the name: onClickCapture, onKeyDownCapture, onPointerDownCapture. Same capture-versus-bubble semantics as native; reach for capture when a parent needs to intercept an event before its children get it.
value is always a string. e.currentTarget.value hands you a string even on <input type="number">, where the DOM stores the raw text you typed, and <input type="date">, where it’s an ISO date string. Converting to a real number or date is your handler’s job. When you reach forms, Zod’s z.coerce.number() does this coercion cleanly at the boundary.
onScroll doesn’t bubble in React. As in the DOM, scroll events don’t propagate up the tree, so attach onScroll to the element that actually scrolls, not to a parent expecting to catch it.
Sort these by what carried over from native versus what is genuinely React’s:
Sort each statement by whether it's behavior you already knew from native DOM events, or something React's synthetic layer changes. Drag each item into the bucket it belongs to, then press Check.
e.preventDefault() cancels the browser’s built-in action for the evente.stopPropagation() halts the event travelling further up the treeonChange on a text input fires on every keystroke, not on blure.currentTarget is typed against the element but e.target is notstopPropagation can stop a listener on document from firingExternal resources
Section titled “External resources”The official walkthrough of event handlers, passing handlers as props, and stopping propagation.
The community reference table for typing ChangeEvent, MouseEvent, FormEvent and friends by element.
The canonical writeup of the root-container listener and why a child's stopPropagation now reaches farther.
The native pointer-event API behind onPointerDown and pointer capture.