⚛️ React Boundary
発展 — 🖱️ イベントシステム

Reactのイベントシステム:SyntheticEventとは何か

onClickなどのReactイベントハンドラが受け取るのは ブラウザのネイティブイベントではなくSyntheticEventです。 なぜわざわざラップするのか、そのメリットと内部の仕組みを解説します。

🔥 問題提起:ブラウザごとに異なるイベントAPI

2013年にReactが登場した頃、ブラウザ間のイベントAPIの非互換性は現在よりもはるかに深刻でした。 Internet Explorerはイベントオブジェクト自体が異なっており、クロスブラウザ対応は大変な作業でした。

当時のクロスブラウザ問題の例

// jQuery以前の時代のクロスブラウザ対応(参考)
function handleEvent(event) {
  // IE8ではeventがwindow.eventだった
  event = event || window.event;
  
  // stopPropagationのIE対応
  if (event.stopPropagation) {
    event.stopPropagation();
  } else {
    event.cancelBubble = true; // IE独自
  }
  
  // targetのIE対応
  const target = event.target || event.srcElement; // IE独自
  
  // keyCodeのブラウザ差異
  const key = event.key || String.fromCharCode(event.keyCode);
}
  • IE8までevent.targetではなくevent.srcElementだった
  • IE8までstopPropagation()がなくcancelBubble = trueだった
  • フォーカス・ブラーイベントのバブリング挙動がブラウザで異なった
  • タッチイベントの実装がブラウザごとに大きく異なった

🎯 結論から言う

SyntheticEventはブラウザのネイティブイベントをW3C仕様に準拠した形でラップしたオブジェクトです。

クロスブラウザ互換性の確保とイベント委譲によるパフォーマンス最適化が主な目的です。

目的1:クロスブラウザ対応

ブラウザの差異をReactが吸収し、開発者は1つのAPIを使えばよい

目的2:イベント委譲によるパフォーマンス

全イベントを1箇所で管理し、メモリ効率とパフォーマンスを向上

🗺️ イベント委譲(Event Delegation)の仕組み

Reactは各コンポーネントに個別のイベントリスナーを取り付けません。 代わりに1つのリスナーをルート要素に取り付け、すべてのイベントをそこで受け取ります。 これを「イベント委譲(Event Delegation)」と呼びます。

イベント委譲の概念図
DOMツリーとイベントの流れ
document / root(React 17+) ← ここに1つのリスナー
↕ バブリング
<div id="root">
↕ バブリング
<App>
↕ バブリング
<Button onClick={handler}> ← 実際のクリック
Reactのイベント処理フロー(概略)
// 1. コンポーネントのonClickはFiberに「ハンドラ」として保存されるだけ
// 実際のDOMへのaddEventListenerはしない

// 2. ルートに取り付けた1つのリスナーがすべてのイベントを受け取る
rootElement.addEventListener('click', reactEventHandler);

// 3. reactEventHandlerの内部処理(概略)
function reactEventHandler(nativeEvent) {
  // a. イベントが発生した実際のDOM要素を取得
  const nativeTarget = nativeEvent.target;
  
  // b. そのDOMからFiberノードを逆引き
  const targetFiber = getFiberFromDom(nativeTarget);
  
  // c. Fiberツリーを上に辿りながらonClickを探す
  let fiber = targetFiber;
  while (fiber !== null) {
    if (fiber.pendingProps.onClick) {
      // d. SyntheticEventを作成してハンドラを呼び出す
      const syntheticEvent = createSyntheticEvent(nativeEvent);
      fiber.pendingProps.onClick(syntheticEvent);
    }
    fiber = fiber.return; // 親へ
  }
}

✅ イベント委譲のメリット

  • 10,000個のボタンがあっても、DOMに取り付けるリスナーは1つだけ
  • コンポーネントのマウント/アンマウント時にリスナーの追加/削除が不要
  • メモリ使用量の大幅な削減
  • イベント処理ロジックを一箇所に集中させられる

🔄 React 17:委譲先がdocumentからrootへ変わった理由

React 16以前では、すべてのイベントをdocumentに委譲していました。 React 17からはReactのルートコンテナ(通常は<div id='root'>に委譲するよう変更されました。

React 16 vs React 17:委譲先の違い
React 16:documentへ委譲
// document レベル
document.addEventListener('click', handler);

// 問題:複数のReactアプリが
// 同じdocumentを共有すると
// 干渉が起きる可能性がある
React 17:rootへ委譲
// root コンテナレベル
rootContainer.addEventListener('click', handler);

// 複数のReactアプリが
// 独立したrootを持ち
// 干渉しない
なぜ変更が必要だったか:マイクロフロントエンドの問題
// マイクロフロントエンド:同一ページに複数のReactアプリ
// React 16の問題:どちらのReactが先にdocumentでキャッチするかで干渉

// アプリA(React 16)
const rootA = document.getElementById('app-a');
ReactDOM.render(<AppA />, rootA);
// → document.addEventListener('click', handlerA)

// アプリB(React 16)- 別バージョンのReact
const rootB = document.getElementById('app-b');
ReactDOM.render(<AppB />, rootB);
// → document.addEventListener('click', handlerB)

// 問題:AppBのコンポーネントがe.stopPropagation()を呼ぶと
//       AppAのdocumentリスナーまでイベントが届かなくなる!

// React 17の解決策:
const rootA = document.getElementById('app-a');
ReactDOM.createRoot(rootA).render(<AppA />);
// → rootA.addEventListener('click', handlerA)

// AppBのstopPropagationはrootAのリスナーに影響しない

実際の影響

この変更はほとんどのアプリで影響なしです。ただし、 document.addEventListenerで手動でリスナーを追加し、 e.stopPropagation()に依存しているコードは動作が変わる可能性があります。 React公式はこれを「グラデュアルアップグレードを簡単にするための変更」と説明しています。

♻️ SyntheticEventのプーリング(React 16まで)

React 16以前では、SyntheticEventオブジェクトはプール(pool)から再利用されていました。 これは多くのバグの原因となった設計で、React 17で廃止されました。

プーリングが原因のバグ(React 16以前)

// React 16以前のコード
function handleChange(event) {
  // ❌ 非同期でeventにアクセスするとnullになる
  setTimeout(() => {
    console.log(event.target.value); // null!!! (プールに返却済み)
  }, 0);
  
  // ❌ クロージャでeventを保持してもnullになる
  setState(prevState => ({
    value: event.target.value  // null!!! (render中にアクセスでも問題あり)
  }));
  
  // ✅ 回避策:event.persist()でプールへの返却を止める
  event.persist();
  setTimeout(() => {
    console.log(event.target.value); // 正常に取得できる
  }, 0);
  
  // ✅ または値を先にコピーしておく
  const value = event.target.value;
  setTimeout(() => {
    console.log(value); // 正常
  }, 0);
}

イベントハンドラが返ると、SyntheticEventオブジェクトはプールに戻り、すべてのプロパティがnullにリセットされていました。 これはGCを減らすためのパフォーマンス最適化でしたが、多くのバグを生みました。

✅ React 17以降:プーリング廃止

React 17でプーリングが廃止されました。SyntheticEventは通常のオブジェクトとして扱われ、 イベントハンドラの外でもアクセスできます。 event.persist()は何もしない空メソッドになりました(後方互換性のため残っています)。

🔧 nativeEventへのアクセス:必要な場面

SyntheticEventでは不足する場合、event.nativeEventで ブラウザのネイティブイベントに直接アクセスできます。

nativeEventを使う場面
function FileDropZone() {
  const handleDrop = (syntheticEvent) => {
    syntheticEvent.preventDefault();
    
    // SyntheticEventにはdataTransferが含まれているが...
    // 場合によってはnativeEventから取得する必要がある
    const files = syntheticEvent.nativeEvent.dataTransfer.files;
    
    // ファイルを処理
    Array.from(files).forEach(file => {
      processFile(file);
    });
  };
  
  return (
    <div onDrop={handleDrop} onDragOver={e => e.preventDefault()}>
      ファイルをここにドロップ
    </div>
  );
}

// nativeEventが必要な別の例:PassiveEventListenerオプション
// ReactのonScrollはpassiveオプションをサポートしていない場合がある
useEffect(() => {
  const element = ref.current;
  const handler = (e) => { /* スクロール処理 */ };
  
  // passiveオプション付きのネイティブリスナーを直接追加
  element.addEventListener('scroll', handler, { passive: true });
  return () => element.removeEventListener('scroll', handler);
}, []);
SyntheticEventのプロパティ(主要なもの)
標準プロパティ(W3C準拠)
  • bubbles, cancelable
  • currentTarget, target
  • defaultPrevented
  • eventPhase
  • isTrusted
  • timeStamp, type
標準メソッド
  • preventDefault()
  • stopPropagation()
  • stopImmediatePropagation()
  • persist()(React 17+は空実装)
  • nativeEvent(ネイティブへのアクセス)

⚠️ stopPropagationの注意点:ReactとDOMの混在

ReactのSyntheticEventのstopPropagation()Reactのイベント伝播を止めますが、DOMのネイティブイベントの伝播は止めません。 これが混乱の原因になります。

よくある落とし穴:ReactとDOM両方でリスナーを使う場合

function Modal({ onClose, children }) {
  // ❌ 問題のあるコード:
  // documentにDOMリスナーを直接追加して「外側クリックで閉じる」を実装
  useEffect(() => {
    document.addEventListener('click', onClose);
    return () => document.removeEventListener('click', onClose);
  }, [onClose]);
  
  return (
    <div
      className="modal"
      onClick={e => {
        // ReactのstopPropagationはReactのイベント伝播を止める
        // しかし、React 17+のrootへのバブリングは続く
        // documentにはネイティブイベントが到達し、onCloseが呼ばれてしまう!
        e.stopPropagation();
      }}
    >
      {children}
    </div>
  );
}

// ✅ 正しい解決策:ネイティブイベントのstopPropagationを呼ぶ
onClick={e => {
  e.nativeEvent.stopImmediatePropagation();
}}

// ✅ またはReact内だけで管理する
function Modal({ onClose, children }) {
  return (
    <div className="backdrop" onClick={onClose}>
      <div
        className="modal"
        onClick={e => e.stopPropagation()} // Reactの伝播のみ止める(backdropのonCloseをブロック)
      >
        {children}
      </div>
    </div>
  );
}
イベント伝播の全体像(React 17+)
クリック発生 DOMキャプチャフェーズ DOMターゲット
Reactキャプチャフェーズ(rootで) Reactバブルフェーズ(rootで)
DOMバブリング(rootより上) document

※ ReactのonClickはrootでのバブルフェーズで処理されます。 DOMのdocument.addEventListenerはその後に実行されます(React 17+)。

🧩 実践的なパターン

パターン1:フォームイベントの正しい扱い

function Form() {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault(); // SyntheticEventのpreventDefault
    // フォーム処理
  };
  
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    // React 17+ではpersist()不要
    // 非同期でもe.target.valueにアクセス可能
    setTimeout(() => {
      console.log(e.target.value); // ✅ React 17+では正常に動作
    }, 0);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input onChange={handleChange} />
    </form>
  );
}

パターン2:カスタムイベントハンドラの型付け

// TypeScriptでのSyntheticEvent型の使い方
import React from 'react';

// クリックイベント
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
  e.preventDefault();
  e.stopPropagation();
  console.log(e.clientX, e.clientY);
};

// 入力イベント
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
  console.log(e.target.value);
};

// キーボードイベント
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  if (e.key === 'Enter') {
    e.preventDefault();
    submitForm();
  }
};

// ドラッグイベント
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
  e.preventDefault();
  const files = e.dataTransfer.files;
  // ...
};

📌 まとめ

  • ✓ SyntheticEventはブラウザのネイティブイベントをW3C準拠でラップしたオブジェクト
  • ✓ クロスブラウザ互換性の確保とイベント委譲によるパフォーマンス最適化が目的
  • ✓ イベント委譲:全イベントを1つのリスナー(root)で受け取りFiberツリーを辿って処理
  • ✓ React 17でdocumentからrootへ委譲先変更:マイクロフロントエンドの独立性確保のため
  • ✓ React 16以前のプーリング(イベント使い回し)はReact 17で廃止、非同期アクセスも安全に
  • ✓ event.nativeEventでブラウザのネイティブイベントに直接アクセスできる
  • ✓ ReactのstopPropagationとdocument直接リスナーを混在させる場合は注意が必要

関連記事