05 — Event Handling

Synthetic Events: What React Wraps and Why

synthetic_event_anatomy.js
// React wraps native DOM events in a cross-browser SyntheticEvent.
// It pools and normalizes browser differences — you never write
// `if (event.srcElement)` or `if (event.target)` cross-browser guards.

function SearchForm() {
  function handleSubmit(event) {
    // SyntheticEvent — same API surface as the native Event interface:
    event.preventDefault()    // stop the browser's default action (form navigation)
    event.stopPropagation()   // stop bubbling to ancestor handlers
    event.target              // the actual DOM element that triggered (could be deeply nested)
    event.currentTarget       // always the element the handler is attached to
    event.nativeEvent         // the raw browser Event object if you need it
    event.isDefaultPrevented() // boolean — has preventDefault been called?
    event.isPropagationStopped()

    const query = new FormData(event.currentTarget).get('query')
    console.log('Searching for:', query)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="query" type="text" />
      <button type="submit">Search</button>
    </form>
  )
}

target vs. currentTarget — The Delegate's Trap

target_vs_currenttarget.js
// event.target       = the element the user ACTUALLY interacted with (could be a child)
// event.currentTarget = the element the HANDLER is attached to (always the same element)

function IconButton({ onActivate }) {
  return (
    <button onClick={(e) => {
      // If the user clicks the <svg> icon or the <span> text:
      console.log(e.target)        // → <svg> or <span> (the actual click target)
      console.log(e.currentTarget) // → <button> (where onClick is registered)
      onActivate()
    }}>
      <svg /><span>Delete</span>
    </button>
  )
}

// PRODUCTION PATTERN: use currentTarget when you need the handler's element,
// target when you need to know what was actually clicked (event delegation).
// Mixing them up is the #1 source of "wrong element" bugs in icon-heavy UIs.

React 17+ Event Delegation Architecture

event_delegation_architecture.js
// ── REACT ≤16: single listener on document ────────────────────────────
// React attached ONE listener per event type to `document` and dispatched
// synthetic events from there. Fast (one listener vs thousands), but broke
// isolation when multiple React trees or non-React widgets shared a page.

// ── REACT 17+: listener on the root container ─────────────────────────
// React attaches listeners to the root DOM container (the element passed
// to createRoot). Each React tree is isolated — no cross-tree event stealing.

// You NEVER write this manually — React does it internally:
// createRoot(document.getElementById('root')).render(<App />)
//   → rootContainerElement.addEventListener('click', dispatchToSyntheticSystem)
//   → rootContainerElement.addEventListener('change', dispatchToSyntheticSystem)
//   → ...one per supported event type, attached ONCE at the root.

// WHY THIS MATTERS:
// 1. Multiple React versions on one page (microfrontends) — each tree's
//    events are scoped to its own root, not the global document.
// 2. Non-React widgets (legacy jQuery, vanilla JS) on the same page —
//    React no longer intercepts their events at the document level.
// 3. Event pooling was removed in React 17 — SyntheticEvent objects are
//    no longer recycled, so accessing them asynchronously is safe.

Handler Binding: Reference vs. Invocation

handler_binding_anti_pattern.js
// ANTI-PATTERN: calling the function during render instead of passing a reference
// onClick={doSomething()} invokes doSomething IMMEDIATELY during render.
// Whatever it returns (probably undefined) becomes the onClick handler.
// The button is now inert — no error, no warning, just silence.

function BadButton({ onDelete, item }) {
  return <button onClick={onDelete(item.id)}>Delete</button>
  // onDelete(item.id) runs during EVERY render, not on click.
  // If onDelete mutates state → infinite loop or immediate deletion.
}
handler_binding_production.js
// PRODUCTION: pass a reference, not an invocation

// 1. No arguments → pass the function reference directly (no parens)
<button onClick={doSomething}>Click</button>

// 2. Arguments needed → wrap in an arrow function (creates a new fn per render)
<button onClick={() => deleteItem(item.id)}>Delete</button>

// 3. Handler factory → returns a stable function reference
function makeDeleteHandler(id) {
  return () => deleteItem(id)
}
<button onClick={makeDeleteHandler(item.id)}>Delete</button>

// 4. bind() — also creates a new function per render, same as arrow fn
<button onClick={deleteItem.bind(null, item.id)}>Delete</button>

// APPROACHES 2 AND 4 create a new function on every render. For most components
// this is fine. It only matters when the child is React.memo'd AND the handler
// is in the dependency array of a useEffect/useCallback. See Chapter 9.

Production Pattern: List Item Handlers

list_item_handlers.js
function TodoList({ todos, onToggle, onDelete }) {
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <input
            type="checkbox"
            checked={todo.done}
            onChange={() => onToggle(todo.id)}
          />
          <span>{todo.text}</span>
          <button onClick={() => onDelete(todo.id)}>Remove</button>
        </li>
      ))}
    </ul>
  )
}

// Every row creates two inline arrow functions per render. Fine for most apps.
// Premature optimization here is a common mistake — reach for useCallback (Ch 9)
// ONLY when you've measured a re-render problem with React DevTools Profiler.

preventDefault Patterns

Forms — The #1 React Beginner Bug

prevent_default_form.js
function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  function handleSubmit(event) {
    event.preventDefault()  // CRITICAL: stops the browser's full-page navigation/reload
    // Without this, the browser navigates → all React state is wiped instantly.
    // The bug looks like "my app randomly resets" — not obviously form-related.
    onSubmit({ email, password })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={e => setEmail(e.target.value)} type="email" />
      <input value={password} onChange={e => setPassword(e.target.value)} type="password" />
      <button type="submit">Log In</button>
    </form>
  )
}
prevent_default_link.js
function CustomLink({ href, onNavigate, children }) {
  function handleClick(event) {
    event.preventDefault()  // stop the browser's real navigation (full reload)
    onNavigate(href)         // let a client-side router handle it (Chapter 19)
  }
  return <a href={href} onClick={handleClick}>{children}</a>
}

Drag-and-Drop — The Silent Non-Fire

prevent_default_dnd.js
function DropZone({ onDrop }) {
  function handleDragOver(event) {
    event.preventDefault()
    // CRITICAL: without preventDefault on dragover, the browser's default
    // behavior is "do not allow drop here" — onDrop will NEVER fire.
    // This is the single most common "my drop handler doesn't work" cause.
  }
  function handleDrop(event) {
    event.preventDefault()
    const files = Array.from(event.dataTransfer.files)
    onDrop(files)
  }
  return <div onDragOver={handleDragOver} onDrop={handleDrop}>Drop files here</div>
}

stopPropagation and Event Bubbling

stop_propagation_modal.js
// React synthetic events bubble like native DOM events, following the same tree order.
// stopPropagation() prevents ancestor handlers from firing.

function Modal({ onClose, children }) {
  return (
    <div className="overlay" onClick={onClose}>
      {/* Without stopPropagation, clicking inside the modal body
          bubbles up to the overlay's onClick and closes the modal. */}
      <div className="modal-body" onClick={(e) => e.stopPropagation()}>
        {children}
      </div>
    </div>
  )
}

// CAVEAT: stopPropagation blocks ALL ancestor listeners, not just the one
// you're avoiding. A modal's inner stopPropagation also prevents an analytics
// "track all clicks" listener higher in the tree from ever seeing that click.
// Use it deliberately, not reflexively — consider event.target checks instead
// if you only need to exclude specific elements.
stop_propagation_alternative.js
// ALTERNATIVE: check the target instead of stopping propagation.
// This lets analytics/other ancestors still see the event.

function Modal({ onClose, children }) {
  return (
    <div className="overlay" onClick={(e) => {
      // Only close if the click was on the overlay ITSELF, not a child
      if (e.target === e.currentTarget) onClose()
    }}>
      <div className="modal-body">
        {children}
      </div>
    </div>
  )
}
// This pattern preserves bubbling for other listeners while achieving
// the same "click outside to close" behavior.

Debouncing High-Frequency Handlers

debounce_search.js
// High-frequency events (onScroll, onMouseMove, onChange hitting an API)
// need debouncing to avoid overwhelming the app or network.

function SearchBox({ onSearch }) {
  const [query, setQuery] = useState('')

  useEffect(() => {
    if (query === '') return  // don't search on empty
    const timeoutId = setTimeout(() => onSearch(query), 300)
    return () => clearTimeout(timeoutId)  // cancels the previous timer on every keystroke
    // Cleanup runs BEFORE the next effect: every keystroke resets the 300ms timer.
    // The API call only fires 300ms after the user STOPS typing.
  }, [query, onSearch])

  return <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search…" />
}
// This pattern (debounce via useEffect + setTimeout + cleanup) is covered in
// full in Chapter 6 — included here as the standard fix for "handler fires too often."

Custom Hooks for Event Logic

use_event_listener.js
import { useEffect, useRef } from 'react'

// PRODUCTION PATTERN: reusable event listener hook for document/window-level events
// that need proper cleanup and optional element targeting.

function useEventListener(eventName, handler, element = window) {
  const savedHandler = useRef(handler)

  // Update the ref each render so the effect always has the latest handler
  // without needing to re-attach the listener (which would cause flicker).
  useEffect(() => {
    savedHandler.current = handler
  }, [handler])

  useEffect(() => {
    // SSR guard — window doesn't exist during server rendering
    if (!(element && element.addEventListener)) return

    const eventListener = (event) => savedHandler.current(event)
    element.addEventListener(eventName, eventListener)
    return () => element.removeEventListener(eventName, eventListener)
  }, [eventName, element])
}

// Usage:
// useEventListener('keydown', handleEscape)        // on window
// useEventListener('click', handleClick, ref.current) // on a specific element

💡 Tips & Tricks

tips.js
// [Idiom] Prefer event.currentTarget over event.target when you need "the element
// this handler is attached to" — especially for delegated click handlers on
// elements with nested icons/spans. target can be any descendant the user clicked.

// [Debug] If a form mysteriously reloads the page or app state resets on submit,
// check for a missing event.preventDefault() in onSubmit FIRST. It's the single
// most common cause of "my app just refreshed for no reason."

// [Performance] Inline arrow functions in onClick={() => doThing(id)} inside list
// items are NOT a performance problem for the vast majority of apps. Don't reach
// for useCallback until you've measured a re-render problem with the Profiler.

// [Idiom] Use onMouseEnter/onMouseLeave instead of onMouseOver/onMouseOut for hover
// UI — Enter/Leave doesn't bubble through children, avoiding flicker when the
// pointer crosses internal element boundaries within the hovered area.

// [Debug] When onKeyDown handlers "miss" certain keys, check whether the element
// is focusable — keyboard events require tabIndex={0} or a naturally focusable
// element (button, input, a[href]). A plain div won't receive keyboard events.

// [Idiom] For Ctrl/Cmd+S "save" shortcuts, use onKeyDown on a focusable container
// or document-level listener — and always preventDefault to stop the browser's
// native "Save Page" dialog.

⚠️ Edge Cases & Gotchas

edge_cases.js
// [Gotcha] Forgetting event.preventDefault() in onSubmit reloads the page and
// wipes ALL state. The browser's default form behavior is a full navigation;
// React state doesn't survive it. The bug often looks unrelated to the form
// since the whole app remounts from scratch.

// [Gotcha] onClick={fn()} calls the function DURING RENDER, not on click.
// A very easy typo: onClick={handleDelete(id)} instead of onClick={() => handleDelete(id)}
// → handleDelete runs immediately on every render, and its return value (undefined)
// silently makes the button inert with NO error.

// [Gotcha] onChange in React fires on EVERY keystroke, not on blur/commit like
// plain HTML. Code ported from a non-React mental model expecting change to fire
// only on blur will see far more invocations than expected. This is deliberate —
// it underpins the controlled-input model (Chapter 14).

// [Gotcha] stopPropagation() inside a child can silently break unrelated ancestor
// logic — a modal's inner stopPropagation also prevents an analytics "track all
// clicks" listener higher in the tree from ever seeing that click. Consider
// target === currentTarget checks as a less destructive alternative.

// [Gotcha] Synthetic event objects were POOLED in React ≤16 and reused across
// events. Accessing event.type asynchronously (inside a setTimeout after the
// handler returns) threw or read null in React 16 and earlier. React 17+ removed
// event pooling — but this still explains "works in new project, breaks in old
// codebase" bugs. If you must support React 16, call event.persist() or extract
// the values you need before the async boundary.

// [Gotcha] React's synthetic event system does NOT support capture-phase
// listeners via the standard addEventListener('click', fn, true) API. React
// uses the onClickCapture prop convention instead:
//   <div onClickCapture={handleCapture}> — fires during capture, before bubbling.

🧠 Spot the Bug

A "select all" checkbox is supposed to toggle every row, but clicking it does nothing — no errors, no console output.

spot_the_bug.js
function SelectAllCheckbox({ onSelectAll }) {
  return <input type="checkbox" onClick={onSelectAll(true)} />
}
Answer

onSelectAll(true) is called immediately during render — it's an invocation, not a reference. Whatever onSelectAll(true) returns (likely undefined, if it's a void function) is what gets assigned to onClick. The checkbox's real click handler is undefined, so clicking it does nothing — while the "select all" logic already ran once during the initial render, without user interaction.

Fix: wrap in an arrow function so it only executes when the event fires:

<input type="checkbox" onClick={() => onSelectAll(true)} />

The lesson: JSX event props need a function reference, not a function call. Any handler that needs arguments must be wrapped: onClick={() => fn(arg)}, not onClick={fn(arg)}.

Key Takeaways

key_takeaways.js
// 1. React wraps native events in SyntheticEvent — same API (preventDefault,
//    stopPropagation, target, currentTarget) plus .nativeEvent for raw access.
//    event.target = actual click target (could be child); currentTarget = handler's element.

// 2. React 17+ attaches event listeners to the root container (not document).
//    Isolates multiple React trees and non-React widgets on the same page.
//    Event pooling was removed — SyntheticEvent objects are safe to access async.

// 3. Handler props need a FUNCTION REFERENCE, not an invocation.
//    onClick={fn}     → correct (reference, no args)
//    onClick={fn()}   → BUG: runs during render, assigns return value (undefined) to onClick
//    onClick={() => fn(arg)} → correct (arrow wrapper for args)

// 4. Missing preventDefault() on onSubmit triggers a full page reload — the
//    #1 React beginner bug. Also required on dragover for drop to work, and
//    on links for client-side routing.

// 5. onChange in React fires per keystroke (not on blur like plain HTML).
//    This underpins the controlled-input model (Chapter 14).

// 6. stopPropagation() blocks ALL ancestor listeners, not just the one you're
//    avoiding. Consider target === currentTarget checks for less destructive
//    "click outside" detection that preserves bubbling for other listeners.