Skip to main content

🧬 Synthetic Events

The e object you've been reading in your handlers isn't the raw browser event — it's a React invention called a SyntheticEvent. It's a universal adapter: one consistent interface that behaves identically across Chrome, Firefox, and Safari. This lesson opens the hood.

Week 4 · Day 3 (Wednesday: Handling Events in React) · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what a SyntheticEvent is and the cross-browser problem it solves
  • Describe how React uses a single delegated listener at the app root
  • List the common SyntheticEvent properties and the specialized event families (mouse, keyboard, focus, form)
  • Explain event pooling and why its removal in React 17 matters for async code
  • Reach the underlying browser event through e.nativeEvent when necessary
  • Avoid the async-access pitfall by capturing values before awaiting

Estimated Time: 55 minutes

Practice: Build an event logger and a keyboard-shortcut listener that compares synthetic vs native.

In This Lesson

What Is a Synthetic Event?

A SyntheticEvent is React's cross-browser wrapper around the native DOM event. Picture a universal power adapter: whatever the wall socket looks like in a given country, your laptop always plugs into the same connector. React does that for events — the browser underneath may expose slightly different APIs, but your handler always receives the same, predictable object.

graph TD A[Native DOM event
from the browser] --> B[React event system] B --> C[SyntheticEvent wrapper] C --> D[Your handler receives e] C --> E[Consistent API
same in every browser] C --> F[Access native event
via e.nativeEvent]

Because the interface is consistent, the browser-sniffing code developers wrote a decade ago simply disappears. You write e.key === 'Enter' once and trust it works everywhere.

Why React Wraps Events

Before frameworks normalized this, attaching a listener meant guarding against browsers that disagreed on the API. It looked like this:

// The bad old days — cross-browser branching by hand
function addListener(el, type, handler) {
  if (el.addEventListener) {
    el.addEventListener(type, handler, false);   // modern browsers
  } else if (el.attachEvent) {
    el.attachEvent('on' + type, handler);        // old Internet Explorer
  } else {
    el['on' + type] = handler;                   // ancient fallback
  }
}

React makes that entire category of code obsolete. You declare the handler in JSX and the wrapper guarantees a single, uniform shape:

function Button() {
  const handleClick = (e) => {
    // e is a SyntheticEvent — identical shape in every browser
    console.log(e.type); // "click"
  };

  return <button onClick={handleClick}>Click me</button>;
}

📖 Same names as the DOM, on purpose

SyntheticEvent deliberately mirrors the native event's interface — type, target, preventDefault(), stopPropagation() all work exactly as you'd expect from the DOM spec. React isn't inventing a new vocabulary; it's guaranteeing the standard one behaves consistently.

Delegation from the Root

Here's a surprise: when you write onClick on a hundred buttons, React does not attach a hundred listeners. It attaches one listener to your app's root container and lets events bubble up to it. When an event arrives, React figures out which component it belongs to and calls the right handler with a freshly built SyntheticEvent.

React attaches one listener at the root and dispatches to component handlers Root container ONE real listener <Button A> <Button B> <Button C> events bubble up to the one listener
One delegated listener at the root handles every component's events. Fewer listeners means less memory and faster mounting for large trees.

💡 A practical consequence

Because React listens at its own root (not document since React 17), mixing React with non-React code or nesting multiple React roots behaves predictably. It also means a plain document.addEventListener in the capture phase can see events before React does — occasionally useful, occasionally a source of confusion.

Properties & Event Families

Every SyntheticEvent shares a common set of properties, and specialized events add more. Here's the common core:

Property / MethodWhat it gives you
typeThe event name, e.g. "click", "keydown"
targetThe element that originated the event
currentTargetThe element whose handler is running
preventDefault()Cancels the browser's default action
stopPropagation()Stops the event travelling to other handlers
nativeEventThe underlying raw browser event
bubbles, cancelable, isTrustedStandard DOM event flags

Mouse events

function MouseDemo() {
  const handleMouse = (e) => {
    console.log(e.type, e.button, e.clientX, e.clientY);
    console.log('modifiers', e.altKey, e.ctrlKey, e.shiftKey);
  };

  return (
    <div
      onClick={handleMouse}
      onMouseEnter={handleMouse}
      onMouseLeave={handleMouse}
      onContextMenu={handleMouse}
    >
      Interact with me
    </div>
  );
}

Keyboard events

function KeyboardDemo() {
  const handleKey = (e) => {
    console.log('key:', e.key);     // "a", "Enter", "ArrowUp"…
    console.log('code:', e.code);   // physical key, e.g. "KeyA"
    console.log('repeat:', e.repeat);
  };

  return <input onKeyDown={handleKey} placeholder="Type here" />;
}

Focus & form events

function FieldDemo() {
  const handleFocus = (e) => console.log('focused', e.target.name);
  const handleBlur  = (e) => console.log('blurred, related:', e.relatedTarget);
  const handleChange = (e) => console.log('value:', e.target.value);

  return (
    <input
      name="email"
      onFocus={handleFocus}
      onBlur={handleBlur}
      onChange={handleChange}
    />
  );
}

✅ Normalized across browsers

Wheel deltas, key names, and input values are all normalized by React. That's why you check e.key === 'Enter' instead of juggling keyCode, which, and charCode the way older code had to.

Event Pooling (and Its Removal)

Historically, React reused a small pool of event objects for performance. After your handler finished, React would reset the event's properties to null and recycle the object for the next event. That optimization bit anyone who tried to use the event asynchronously.

// React 16 and earlier — event pooling in effect
function Legacy() {
  const handleClick = (e) => {
    console.log(e.type); // "click" — fine, synchronous

    setTimeout(() => {
      console.log(e.type); // null! the event was recycled
    }, 1000);

    // The old workaround: opt out of pooling for this event
    e.persist();
    setTimeout(() => console.log(e.type), 1000); // now "click"
  };

  return <button onClick={handleClick}>Click</button>;
}

✅ React 17+ removed pooling

Modern browsers made the optimization unnecessary, so React 17 dropped event pooling entirely. Event objects are no longer nulled out, e.persist() is a harmless no-op, and reading properties after an await just works:

function Modern() {
  const handleClick = (e) => {
    console.log(e.type);               // "click"
    setTimeout(() => console.log(e.type), 1000); // still "click"
  };

  return <button onClick={handleClick}>Click</button>;
}

⚠️ One caveat remains

The event object survives, but the DOM it points at can still change. e.target.value read after an await may have been edited by the user in the meantime. Copy the values you need before awaiting — see the gotchas below.

Reaching the Native Event

Occasionally you need a property React doesn't surface, or you're integrating with a library expecting a raw DOM event. Every SyntheticEvent carries the original on e.nativeEvent:

function NativeAccess() {
  const handleClick = (e) => {
    const native = e.nativeEvent;    // the real browser MouseEvent
    console.log('synthetic type:', e.type);
    console.log('native type:', native.type);
    // Some low-level properties live only on the native event:
    console.log('composed path:', native.composedPath());
  };

  return <div onClick={handleClick}>Compare synthetic vs native</div>;
}

For events React doesn't expose as a prop at all (custom events, certain non-passive listeners), attach a native listener directly with a ref and clean it up in an effect:

import { useRef, useEffect } from 'react';

function CustomListener() {
  const boxRef = useRef(null);

  useEffect(() => {
    const el = boxRef.current;
    if (!el) return;

    const handler = (e) => console.log('native custom event', e.detail);
    el.addEventListener('my-custom-event', handler);

    // Always clean up — this prevents duplicate listeners and leaks.
    return () => el.removeEventListener('my-custom-event', handler);
  }, []);

  return <div ref={boxRef}>Listening for a native custom event</div>;
}

Common Gotchas

1. Async access to mutable DOM properties

The event survives async work in React 17+, but the element it points to can change. Snapshot what you need first:

const handleChange = async (e) => {
  // ✅ Capture the value NOW, before any await
  const value = e.target.value;

  await saveToServer(value);

  // ❌ Reading e.target.value here could be a newer keystroke
  console.log('saved:', value);
};

2. target vs currentTarget in delegated handlers

function List() {
  const handleClick = (e) => {
    // currentTarget is always the <ul> (where the handler lives)
    // target is whatever <li> or child was actually clicked
    console.log(e.currentTarget.tagName, '←', e.target.tagName);
  };

  return (
    <ul onClick={handleClick}>
      <li>One</li>
      <li>Two</li>
    </ul>
  );
}

3. Native listeners fire outside React's timeline

An event caught by a manual addEventListener isn't part of React's batched update cycle in the same way. Keep such listeners for genuinely native needs (scroll performance, third-party widgets) and clean them up in the effect's return function.

Practice & Quiz

🏋️ Exercise 1: An event logger

Goal: Build an EventLogger that records the last five events fired inside a box, showing each event's type and its target tag name.

function EventLogger() {
  const [log, setLog] = useState([]);

  const record = (e) => {
    // TODO: append { type, tag } and keep only the last 5
  };

  return (
    <div onClick={record} onKeyDown={record}>
      <input placeholder="Type or click" />
      <ul>{log.map((x, i) => <li key={i}>{x.type} — {x.tag}</li>)}</ul>
    </div>
  );
}
💡 Hint

Read e.type and e.target.tagName. Use the updater form and slice(-4) to keep only the most recent entries before appending the new one.

✅ Solution
const record = (e) => {
  const entry = { type: e.type, tag: e.target.tagName };
  setLog((prev) => [...prev.slice(-4), entry]);
};

🏋️ Exercise 2: Synthetic vs native

Goal: On a single click, log both the SyntheticEvent's type and the native event's type, proving they match, then log one property that only exists on the native event.

✅ Solution
const handleClick = (e) => {
  console.log('synthetic:', e.type);        // "click"
  console.log('native:', e.nativeEvent.type); // "click"
  console.log('path:', e.nativeEvent.composedPath()); // native-only
};

🎯 Quick Quiz

Question 1: How many real DOM listeners does React attach for 50 onClick buttons?

Question 2: In React 17+, what does e.persist() do?

Question 3: You need a property React doesn't expose. Where do you look?

Best Practices & Pitfalls

✅ Do

  • Trust the abstraction — write standard event code and let React normalize browsers
  • Snapshot mutable values (like e.target.value) before any await
  • Reach for e.nativeEvent only when React genuinely doesn't expose what you need
  • Clean up any manual addEventListener in the effect's cleanup function

❌ Don't

  • Rely on old event-pooling behavior or add e.persist() "just in case" — it's obsolete
  • Assume e.target is the element you attached the handler to — that's currentTarget
  • Mix a native document-level listener with React handlers without understanding the ordering
  • Store the whole event object in state to "use later" — copy the fields you need instead

💡 The mental model to keep

SyntheticEvent = "the DOM event, guaranteed consistent." One delegated listener, one predictable interface, and a hatch (nativeEvent) for the rare low-level need. If you remember only that, you'll reason about React events correctly.

Summary

🎉 Key Takeaways

  • A SyntheticEvent is React's cross-browser wrapper mirroring the native DOM event interface
  • React uses event delegation — a single listener at the app root, not one per element
  • Events come in families: mouse, keyboard, focus, form — each with specialized properties
  • Event pooling was removed in React 17; e.persist() is now a no-op
  • Reach the raw browser event via e.nativeEvent when you need low-level details
  • The event survives async code, but snapshot mutable DOM values before awaiting

📚 Additional Resources

🚀 What's Next?

You understand the event object inside and out. Now let's put it to constant use where events matter most: Forms in React — controlled inputs, the value/onChange loop, validation, and managing many fields with one handler.

🎉 Under the hood, mastered!

You now know what that little e really is and how React makes it behave everywhere.