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:
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¶
- Tag each interactive element with
data-*attributes encoding the identity it needs (line number, file path, action kind): - Install one top-level event handler (e.g. on the root of the diff container).
- Read the
data-*attributes fromevent.target(orevent.target.closest('[data-action]')for delegation through children) to determine what to do: - 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 JSMap(patterns/constant-time-state-map) keyed by the string identifier. - Mixing delegated + per-component handlers inconsistently. Pick one per interaction type.
Related¶
- concepts/react-re-render — per-component handler recreation is a re-render-cost source this pattern eliminates.
- concepts/hot-path — where this pattern's payoff lives.
- concepts/dom-node-count — the scale axis that motivates it.
- patterns/component-tree-simplification — the companion pattern at the component-layer axis.
- patterns/constant-time-state-map — typical partner for looking up dispatch targets by string key.
- systems/github-pull-requests — canonical wiki instance.