Skip to content

PATTERN Cited by 1 source

Single top-level event handler

Intent

Replace N per-component event handlers with one top-level handler that dispatches on DOM data-attribute values. Trades an O(1) data-attribute inspection per event for eliminating N per-component handler closures.

Context

The React (and Vue, Angular) default pattern is to attach event handlers directly to the component that owns the behavior:

<tr onMouseEnter={handleEnter} onMouseLeave={handleLeave} onClick={handleClick}>

At small scale this reads well and is idiomatic. At hot-path scale (concepts/hot-path) — 10,000+ rows, each with 5-6 handlers — this shape compounds:

  • Each handler is a closure captured per render (unless useCallback + stable deps). New closure identity per render breaks child memoization.
  • Each handler is a separate React internal record; the synthetic event system has to install N of them.
  • Handlers typically capture component state → retain it in the closure graph, contributing to JS heap pressure.

GitHub's v1 diff-line component had 5-6 React event handlers per component × 8-13 components per diff line = 20+ handlers per diff line. At 10,000 diff lines this is 200,000+ handlers.

Mechanism

  1. Tag each interactive element with data-* attributes encoding the identity it needs (line number, file path, action kind):
    <div data-line="L8" data-path="src/foo.tsx" data-action="select">
    
  2. Install one top-level event handler (e.g. on the root of the diff container).
  3. Read the data-* attributes from event.target (or event.target.closest('[data-action]') for delegation through children) to determine what to do:
    diffRoot.addEventListener('mouseenter', (e) => {
      const target = e.target.closest('[data-action]');
      if (!target) return;
      const line = target.dataset.line;
      const path = target.dataset.path;
      // dispatch based on data-attrs
    }, true);
    
  4. Remove per-component handlers.

This is the DOM-standard event delegation pattern — long pre- dates React, but React idioms often obscure it. The data-* attributes live on the DOM, not in React state, so they don't trigger re-renders.

Canonical instance

GitHub's Files-changed tab v2 — "Event handling is now managed by a single top-level handler using data-attribute values. So, for instance, when you click and drag to select multiple diff lines, the handler checks each event's data-attribute to determine which lines to highlight, instead of each line having its own mouse enter function. This approach streamlines both code and improves performance." Part of the v1 → v2 rewrite that cut components-rendered 74 % and INP 78 % (sources/2026-04-03-github-the-uphill-climb-of-making-diff-lines-performant).

Trade-offs

  • Handler cost scales with interaction rate, not row count. This is the reason it's faster at scale.
  • Event delegation requires the DOM structure to be stable. If interactive elements move between parents, the top-level handler needs to know.
  • Harder to encapsulate per-component behavior. The top-level handler becomes a hotspot for action routing; needs discipline to stay maintainable. Consider a dispatch table keyed by data-action.
  • Keyboard / accessibility semantics must be preserved — if each row was <tr tabindex="0" onKeyDown={...}> before, the delegated pattern still needs to emit correct focus events.
  • Not ideal for forms / inputs where browser-default handling
  • uncontrolled inputs interact with per-field handlers.

Anti-patterns

  • Delegation without measurement. At low interaction rates and moderate row counts, per-component handlers are fine. This pattern is for 10,000+ items.
  • Losing the data needed for dispatch. data-* values are strings only; complex dispatch payloads need encoding or an indirection into a JS Map (patterns/constant-time-state-map) keyed by the string identifier.
  • Mixing delegated + per-component handlers inconsistently. Pick one per interaction type.
Last updated · 200 distilled / 1,178 read