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仕様に準拠した形でラップしたオブジェクトです。
クロスブラウザ互換性の確保とイベント委譲によるパフォーマンス最適化が主な目的です。
ブラウザの差異をReactが吸収し、開発者は1つのAPIを使えばよい
全イベントを1箇所で管理し、メモリ効率とパフォーマンスを向上
🗺️ イベント委譲(Event Delegation)の仕組み
Reactは各コンポーネントに個別のイベントリスナーを取り付けません。 代わりに1つのリスナーをルート要素に取り付け、すべてのイベントをそこで受け取ります。 これを「イベント委譲(Event Delegation)」と呼びます。
// 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'>)に委譲するよう変更されました。
// document レベル
document.addEventListener('click', handler);
// 問題:複数のReactアプリが
// 同じdocumentを共有すると
// 干渉が起きる可能性がある // 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で
ブラウザのネイティブイベントに直接アクセスできます。
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);
}, []); - 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の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直接リスナーを混在させる場合は注意が必要