Skip to content
Chapter 23Lesson 6

Synthetic events

How React's synthetic event system wraps and types the native DOM events you handle in a React component.

Back in “The event model” you learned the native event system from the ground up: addEventListener, the capture and bubble phases, event.target versus event.currentTarget, e.preventDefault() and e.stopPropagation(), and delegation, where one listener on a shared ancestor handles clicks on every descendant. That was the browser’s model, written by hand.

Now JSX hands you onClick, onChange, onSubmit as props you write directly 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 everything about it behaves the way the native event did. This lesson is the translation table. It covers what carries over, which is most of it; what you have to type by hand, which is the daily friction for newcomers; and three behaviors that cause real production bugs precisely because the names match native but the semantics don’t.

Start with the smallest handler there is, a button that does something when you click it.

SaveButton.tsx
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 that button is clicked, React calls handleSave and hands it one argument, the event, which by convention you name e. Log it and you’ll see something familiar: it has type, target, currentTarget, preventDefault(), stopPropagation(). That’s the same shape you inspected on a native event in DevTools. It isn’t a native event, though. 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, the link navigation, the checkbox toggle. e.stopPropagation() still halts the event from travelling further up the tree. The event still bubbles : a click on a child fires the child’s onClick, then its parent’s onClick, then its grandparent’s, exactly as the DOM does. You’re not learning a new event model, just a thin layer on top of the one you already know.

If you ever need the raw browser event, e.nativeEvent is the native Event underneath. You’ll reach for it rarely. The one realistic case is calling something that lives only on the native event, like e.nativeEvent.stopImmediatePropagation(), which has no synthetic equivalent.

So why does the wrapper exist at all? Historically, browsers disagreed about event details, and the synthetic layer normalized those quirks so you wrote one handler that worked everywhere. By 2026 those quirks have largely evaporated, but the wrapper stayed, because normalization was never its only job. The synthetic event is also how React integrates events with its render scheduling. The dispatch runs through React’s own tree, which lets React batch the work your handler triggers: every setState you fire inside one handler collapses into a single re-render, the batching you saw earlier in this chapter. So the wrapper isn’t legacy weight you put up with. It’s the seam where the event system and the render system meet.

This is the part that trips up most people new to React with TypeScript, so it’s worth a careful look. The question is simple: when you extract a handler to a named function, what type does the event parameter take?

The rule is that 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>) => {};

Those four cover the vast majority of what you’ll write. The rest of the family, FocusEvent, PointerEvent, ClipboardEvent, DragEvent, and so on, lives in React’s types under the same naming, and you reach for them the same way: import from react, parameterize with the element.

There’s a shortcut that saves you from typing any of this most of the time. An inline handler infers its parameter type from where it sits in the JSX. Write <button onClick={(e) => ...}> and TypeScript already knows e is MouseEvent<HTMLButtonElement>. It reads the type off the onClick prop, and that prop sits on a <button>, so it knows the element. You annotate only when you extract the handler to a named const outside the JSX, because at that point there’s no prop position left for TypeScript to read the type from.

That gives you a clean reflex:

  • Inline arrow in the JSX → no annotation. The prop position supplies the type.
  • Extracted to a named handler → annotate the parameter. You’ve left the prop position, so you state the type yourself.

This is the same TypeScript stance the whole course runs on: lean on inference at the boundaries, annotate at the seams. The two shapes side by side:

<button type="button" name="save" onClick={(e) => console.log(e.currentTarget.name)}>
Save
</button>

No annotation needed. TypeScript infers e as MouseEvent<HTMLButtonElement> straight from the onClick prop, and e.currentTarget is fully typed as the button. This is the default for any handler short enough to read inline.

One thing to watch for while you’re importing these: pulling MouseEvent from react shadows the global DOM MouseEvent type in that file. That’s intended, because inside a React component you want React’s typed version, not the raw DOM one, so leave the shadowing in place.

A quick check that you can pick the right type for a given 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 box
const 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();
};

Here’s the first behavior that catches people out, and it shows up in TypeScript rather than at runtime. To refresh the distinction you learned natively: currentTarget is the element the handler is attached to, and target is the deepest element the event originated on. That part is the same as native.

The React-specific catch is in the types. Because the handler’s prop position pins down which element it’s on, e.currentTarget is typed as that element, so on an input handler e.currentTarget.value is fully typed, with no cast. But e.target is typed as the generic EventTarget, which has no .value, no .checked, no .name. Reaching for e.target.value is a TypeScript error before you’ve even run the code.

const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
};

TypeScript error: Property 'value' does not exist on type 'EventTarget'. target is typed as the bare EventTarget because, in the general case, the event could have originated on any descendant element, so React can’t promise it’s the input.

So the reflex is to reach for currentTarget. For reading values off the element a handler is bound to, which is the everyday case, it’s the typed, correct choice, and it sidesteps the casting entirely.

There’s a nuance worth naming so it doesn’t surprise you later. currentTarget and target point at the same element when the handler sits directly on the element you interacted with, which is the common case of a handler right on the input. They only diverge under delegation, where the handler is on a parent and you clicked a child. That’s the one situation where you’d deliberately read target, and narrow it, since you know which element you expect. But for reading a control’s own value, currentTarget is the right default.

This is the most counterintuitive part of the lesson, and the source of a stopPropagation surprise that stale tutorials get wrong. You already understand delegation from “The event model”: one listener on an ancestor handling many descendants. The new fact is where React puts that listener.

The mental image you probably hold is that each onClick prop becomes a native addEventListener call on that specific 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 React mounts your whole tree into. Your onClick props aren’t native listeners on each button; they’re entries in React’s internal tree. When you click a button, the click bubbles up the real DOM to that one root listener. React then looks at where the click landed and dispatches a synthetic event back down through your component tree, firing the matching onClick handlers along the way.

Why should you care where the listener lives? There are two consequences, and the second is the one that causes bugs.

The first is harmless: multiple independent React trees on one page don’t fight. Before React 17, React attached its single listener to document, so two separately-mounted React apps on the same page stomped on each other’s document-level handling. Now each root owns its own listener at its own container, and they coexist cleanly. You’ll rarely run two React roots in a Next.js SaaS, but this is the design reason 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 lives at the root container rather than at document, a React handler that calls e.stopPropagation() stops the native event from bubbling past the root, which means a native listener you attached to document (or to any ancestor above the root) never fires. The event is contained before it ever gets there.

This is the exact opposite of the pre-React-17 behavior, and it’s why old articles mislead. Back then React listened at document itself, so by the time your React handler ran, the native document listener had already received the event, and stopPropagation inside React couldn’t un-fire it. People wrote that down, and the note outlived the behavior.

The durable rule is that a React child’s stopPropagation() contains the event within the root container. So a React component deep in your tree can silently suppress a global native document listener, such as the window or document keyboard-shortcut handlers you learned to attach natively, or a third-party DOM library listening above the root. That’s the surprise to watch for. Native listeners attached below the root still interleave with React handlers by ordinary DOM bubbling order, so nothing strange happens there.

The diagram makes “the listener is at the root, not on the button” concrete:

Real DOM
document native click listener can be suppressed
<div id="root"> React's ONE click listener one per event type
<form>
<button> clicked
click bubbles up to the one root listener
React tree
<App>
<SignupForm>
<SubmitButton> onClick runs
down to the matched handler
One listener at the root container, dispatched through React's tree, so a child's `stopPropagation` stops the event before it ever reaches `document`.

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?

No — the click is contained at the root container before it can bubble up to document, so that listener never runs.
Yes — calling stopPropagation inside a React handler can only silence other React handlers, not a native listener on document.
Yes — stopPropagation only cancels the capture phase, and the document listener is a bubble-phase listener.
It depends — the outcome changes based on whether the button also has its own native click listener attached.

The third difference is short, and it confuses anyone who first read about the native change event on MDN. There, change 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. Under the hood React wires onChange to the native input event, not the native change event, treating onInput and onChange as the same thing. The name is borrowed from the native world, but the behavior is the native input event’s.

This isn’t a flaw. It’s exactly what makes live-updating UI work. The canonical pattern is to push 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. Try it for yourself:

Type in the box and watch the line below update on every single keystroke — that's `onChange` firing each time, not waiting for blur. Try adding a second `<p>` that shows the value in uppercase with `text.toUpperCase()`.

Preview LIVE

Whether to mirror that value back into the input (a controlled input, as above) or let the DOM hold it (an uncontrolled input) is a real design decision, but it belongs to forms, which the course covers in depth later when it reaches Server Actions. The only thing to carry forward here is the event fact: onChange is per-keystroke, and that is what live UI relies on.

You’ll meet onSubmit constantly, so it’s worth covering here, though only the event mechanic, since forms proper are a later topic.

A <form> has a default action: when submitted, the browser navigates, doing a full-page reload to the form’s action URL. To handle the submit in JavaScript instead, you 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 thrown away. With it, the submit is handled by your code. When you extract the handler, its type is FormEvent<HTMLFormElement>.

One reason not to over-invest in this pattern: in 2026 you’ll write preventDefault on a form far less often than older code does. The modern React form is built on the <form action={fn}> prop. You hand the form a function, and that function owns the entire submit lifecycle, preventDefault included, without you writing it. The course gives forms their own dedicated unit later, covering the action prop, reading submitted data, validation, and pending and error states. For now, recognize the onSubmit plus preventDefault shape when you see it, and know that the action={fn} pattern is what replaces most of it.

Keyboard handling is a small but high-value family the rest of the course leans on, so learn the one reach 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, so don’t reach for them. There’s also e.code, which names the physical key regardless of keyboard layout and is useful 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. Reading the key is all this lesson teaches, though. The focus management and accessibility of dismissible overlays, such as where focus goes when the modal closes and trapping Tab inside it, is its own topic the course handles later with shadcn’s overlay components. Read the key here; manage the focus there.

For chord shortcuts like ⌘K, the modifier keys 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 onKeyPress. onKeyDown fires before the value changes and covers every key: modifiers, arrows, Escape, all of them. onKeyPress is deprecated. It only ever fired for printable characters and was dropped from the DOM spec.

Pointer events, the unified input primitive

Section titled “Pointer events, the unified input primitive”

When code has to handle both mouse and touch, such as a drag handle, a custom slider, or any gesture, there’s one correct reach in 2026, and it’s not the old mouse-plus-touch pairing.

Use pointer events: onPointerDown, onPointerMove, onPointerUp cover mouse, touch, and pen through a single API. When you need to branch on the input type, e.pointerType tells you which: 'mouse', 'touch', or 'pen'. The legacy approach wires up onMouseDown and onTouchStart and then reconciles the two, which means every interaction gets handled twice. That double-handling is exactly what pointer events were created to replace.

<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 one piece worth knowing concretely is that setPointerCapture call. Pointer capture tells the browser to keep sending pointer events to this element even after the pointer moves outside it. That’s precisely what a drag needs, since your cursor moves past the handle’s edges the moment you start dragging. Without it, the drag drops the instant you leave the element, which is why serious drag code uses pointer events.

Building a full drag-and-drop system, such as reordering a list or dragging cards between columns, is what libraries like @dnd-kit exist for, and that’s out of scope here. The takeaway is the platform default: for anything cross-input, reach for onPointer*, not onMouse* plus onTouch*.

Here are three small things to recognize. None of them earns its own section, but you’ll hit each one.

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. These are the same capture-versus-bubble semantics you learned natively. The bubble form is your daily reach; capture is the niche case where a parent needs to intercept an event before its children get it.

value is always a string. e.currentTarget.value always 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 an actual number or date is your handler’s job, since the event never hands you a typed number. So don’t blindly Number(e.currentTarget.value) and trust it, and don’t expect a Date. When you reach forms later, schema validation with Zod’s z.coerce.number() is where this coercion gets handled cleanly at the boundary, but the trap is worth knowing now.

onScroll doesn’t bubble in React. Matching the DOM, scroll events don’t propagate up the tree. Attach onScroll to the element that actually scrolls, not to a parent expecting to catch it.

That’s the whole event surface. To consolidate the lesson’s one big idea, what carried over from native versus what is genuinely React’s, sort these:

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.

Same as native Behaves exactly like the DOM event you already knew
React-specific New behavior the synthetic layer adds
e.preventDefault() cancels the browser’s built-in action for the event
e.stopPropagation() halts the event travelling further up the tree
Both a capture and a bubble phase exist
An event fired on a child runs the parent’s handler too
A single listener for each event type lives at the app’s root container
onChange on a text input fires on every keystroke, not on blur
e.currentTarget is typed against the element but e.target is not
A child calling stopPropagation can stop a listener on document from firing

If those landed cleanly, you’ve got the map. onClick, onChange, onSubmit are the native event model, wrapped in a SyntheticEvent, typed by element, and dispatched from one listener at React’s root. You can now name the three differences from native, and you have three event families to reach for by reflex.