useEffectEvent は React 18.3 で追加された新しいフックで、エフェクトから非リアクティブなロジックを抽出するために設計されています。このフックを使うと、props や state の最新値を読み取りながらも、それらの変化でエフェクトを再実行させないようにできます。
⚠️ 実験的機能に関する注意
useEffectEvent は比較的新しいフックであり、React の機能フラグ enableUseEffectEventHook で有効化されています。使用する際は、React のバージョンと機能の安定性を確認してください。
1. なぜ useEffectEvent が必要か
1.1 エフェクトと依存配列の問題
通常、エフェクト内でリアクティブな値(props や state)にアクセスする場合、それを依存配列に含める必要があります。
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
// ❌ 問題: theme を使うと依存配列に追加が必要
showNotification('Connected!', theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]); // theme が変わるたびに再接続してしまう!
}
問題: theme を依存配列に追加すると、テーマが変わるたびにチャットルームへの再接続が発生してしまいます。しかし、通知を表示するために theme の最新値は必要です。
1.2 useEffectEvent が解決すること
useEffectEvent を使うと、最新の props や state を読み取りながら、それらの変化でエフェクトを再実行させないことができます。
function ChatRoom({ roomId, theme }) {
// ✅ エフェクトイベントを定義
const onConnected = useEffectEvent(() => {
// theme の最新値を読み取れるが、エフェクトの依存値にはならない
showNotification('Connected!', theme);
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
onConnected(); // エフェクトイベントを呼び出す
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // theme は依存配列に不要!
}
結果: roomId が変わった時のみ再接続し、theme が変わっても再接続は発生しません。しかし通知は常に最新の theme で表示されます。
1.3 useEffectEvent の API
const onSomething = useEffectEvent(callback)
| 引数 | 説明 |
|---|---|
callback |
エフェクトイベントのロジックを含む関数。呼び出された瞬間の props や state の最新値にアクセスできる |
返り値: エフェクトイベント関数。この関数は useEffect、useLayoutEffect、useInsertionEffect 内で呼び出すことができます。
1.4 リアクティブな値 vs 非リアクティブな値
1.5 重要な制約
useEffectEvent には厳格な使用ルールがあります:
| 制約 | 理由 |
|---|---|
| ❌ エフェクト外で呼び出せない | レンダー中に呼び出すとエラーになる |
| ❌ 他のコンポーネントに渡せない | 安定した参照が保証されないため |
| ❌ 依存配列を避けるためだけに使わない | バグが隠蔽され、コードが理解しにくくなる |
| ✅ 非リアクティブなロジック専用 | 値の変化に依存しないロジックのみ |
function Component() {
const onClick = useEffectEvent(() => {
console.log('clicked');
});
// ❌ レンダー中に呼び出すとエラー!
onClick();
return <button onClick={() => onClick()}>Click</button>;
}
2. useEffectEvent の内部構造を徹底解剖
2.0 全体像: useEffectEvent の処理フロー
🎣 useEffectEvent(フック呼び出し)
↓
📝 ref オブジェクト { impl: callback } を作成
↓
📋 updateQueue.events に payload を追加
↓
🎨 レンダーフェーズ完了
↓
🔄 コミットフェーズ: ref.impl を最新の callback に更新
↓
⚡ エフェクト実行時: ref.impl を呼び出し
↓
✨ 常に最新の props/state にアクセス可能
2.1 エントリポイント: packages/react/src/ReactHooks.js
// packages/react/src/ReactHooks.js
export function useEffectEvent<Args, F: (...Array<Args>) => mixed>(
callback: F,
): F {
const dispatcher = resolveDispatcher();
// $FlowFixMe[not-a-function] This is unstable, thus optional
return dispatcher.useEffectEvent(callback);
}
2.2 コア実装: mountEvent と updateEvent
初回レンダー時の処理 (mountEvent)
// packages/react-reconciler/src/ReactFiberHooks.js
function mountEvent<Args, Return, F: (...Array<Args>) => Return>(
callback: F,
): F {
const hook = mountWorkInProgressHook();
const ref = {impl: callback};
hook.memoizedState = ref;
// $FlowIgnore[incompatible-return]
return function eventFn() {
if (isInvalidExecutionContextForEventFunction()) {
throw new Error(
"A function wrapped in useEffectEvent can't be called during rendering.",
);
}
return ref.impl.apply(undefined, arguments);
};
}
ポイント:
-
refオブジェクトを作成し、implプロパティに callback を保存 - 返される
eventFnは、呼び出されるたびにref.implを実行 - レンダー中に呼び出されるとエラーをスロー
更新時の処理 (updateEvent)
// packages/react-reconciler/src/ReactFiberHooks.js
function updateEvent<Args, Return, F: (...Array<Args>) => Return>(
callback: F,
): F {
const hook = updateWorkInProgressHook();
const ref = hook.memoizedState;
useEffectEventImpl({ref, nextImpl: callback});
// $FlowIgnore[incompatible-return]
return function eventFn() {
if (isInvalidExecutionContextForEventFunction()) {
throw new Error(
"A function wrapped in useEffectEvent can't be called during rendering.",
);
}
return ref.impl.apply(undefined, arguments);
};
}
2.3 イベント更新のスケジューリング: useEffectEventImpl
// packages/react-reconciler/src/ReactFiberHooks.js
function useEffectEventImpl<Args, Return, F: (...Array<Args>) => Return>(
payload: EventFunctionPayload<Args, Return, F>,
) {
currentlyRenderingFiber.flags |= UpdateEffect;
let componentUpdateQueue: null | FunctionComponentUpdateQueue =
(currentlyRenderingFiber.updateQueue: any);
if (componentUpdateQueue === null) {
componentUpdateQueue = createFunctionComponentUpdateQueue();
currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
componentUpdateQueue.events = [payload];
} else {
const events = componentUpdateQueue.events;
if (events === null) {
componentUpdateQueue.events = [payload];
} else {
events.push(payload);
}
}
}
ポイント:
-
UpdateEffectフラグを設定して、コミットフェーズでの更新をスケジュール -
payloadをupdateQueue.events配列に追加 - payload には
{ ref, nextImpl }が含まれる
2.4 EventFunctionPayload の構造
// packages/react-reconciler/src/ReactFiberHooks.js
type EventFunctionPayload<Args, Return, F: (...Array<Args>) => Return> = {
ref: {
eventFn: F,
impl: F,
},
nextImpl: F,
};
export type FunctionComponentUpdateQueue = {
lastEffect: Effect | null,
events: Array<EventFunctionPayload<any, any, any>> | null, // ← ここ!
stores: Array<StoreConsistencyCheck<any>> | null,
memoCache: MemoCache | null,
};
2.5 コミットフェーズでの更新
コミットフェーズで ref.impl が最新の callback に更新されます:
// packages/react-reconciler/src/ReactFiberCommitWork.js
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent: {
if (enableUseEffectEventHook) {
if ((flags & Update) !== NoFlags) {
const updateQueue: FunctionComponentUpdateQueue | null =
(finishedWork.updateQueue: any);
const eventPayloads =
updateQueue !== null ? updateQueue.events : null;
if (eventPayloads !== null) {
for (let ii = 0; ii < eventPayloads.length; ii++) {
const {ref, nextImpl} = eventPayloads[ii];
ref.impl = nextImpl; // ← ここで最新の callback に更新!
}
}
}
}
break;
}
2.6 レンダー中の呼び出し検出
useEffectEvent で返された関数がレンダー中に呼び出されると、エラーがスローされます:
// packages/react-reconciler/src/ReactFiberWorkLoop.js
export function isInvalidExecutionContextForEventFunction(): boolean {
// Used to throw if certain APIs are called from the wrong context.
return (executionContext & RenderContext) !== NoContext;
}
2.7 なぜ常に最新の値が見えるのか
キーポイント:
- レンダー中に新しい callback が
nextImplとして保存される - コミットフェーズで
ref.implがnextImplに更新される - エフェクト実行時には、常に最新の callback が
ref.implに入っている - つまり、常に最新の props/state をクロージャ経由で参照できる
2.8 テストで確認する動作
React の公式テストで、useEffectEvent の動作が確認されています:
// packages/react-reconciler/src/__tests__/useEffectEvent-test.js
// @gate enableUseEffectEventHook
it('event handlers always see the latest committed value', async () => {
let committedEventHandler = null;
function App({value}) {
const event = useEffectEvent(() => {
return 'Value seen by useEffectEvent: ' + value;
});
// Set up an effect that registers the event handler with an external
// event system (e.g. addEventListener).
useEffect(
() => {
// Log when the effect fires. In the test below, we'll assert that this
// only happens during initial render, not during updates.
Scheduler.log('Commit new event handler');
committedEventHandler = event;
return () => {
committedEventHandler = null;
};
},
// Note that we've intentionally omitted the event from the dependency
// array. But it will still be able to see the latest `value`. This is the
// key feature of useEffectEvent that makes it different from a regular closure.
[],
);
return 'Latest rendered value ' + value;
}
// Initial render
const root = ReactNoop.createRoot();
await act(() => {
root.render(<App value={1} />);
});
assertLog(['Commit new event handler']);
expect(root).toMatchRenderedOutput('Latest rendered value 1');
expect(committedEventHandler()).toBe('Value seen by useEffectEvent: 1');
// Update
await act(() => {
root.render(<App value={2} />);
});
// No new event handler should be committed, because it was omitted from
// the dependency array.
assertLog([]);
// But the event handler should still be able to see the latest value.
expect(root).toMatchRenderedOutput('Latest rendered value 2');
expect(committedEventHandler()).toBe('Value seen by useEffectEvent: 2');
});
引用元: packages/react-reconciler/src/tests/useEffectEvent-test.js
テストのポイント:
- エフェクトは依存配列が
[]なので、初回のみ実行 - しかし
committedEventHandler()は常に最新のvalueを返す - これが
useEffectEventの核心的な機能
3. ユースケース
3.1 チャットルームへの接続と通知
最も一般的なユースケースは、エフェクトの依存値とは無関係に最新の状態を読み取る場合です:
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
// ✅ roomId が変わった時のみ再接続
// ✅ theme が変わっても再接続しない
// ✅ 通知は常に最新の theme で表示
}
3.2 ページ訪問のログ記録
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
const onNavigate = useEffectEvent((visitedUrl) => {
// numberOfItems は非リアクティブ
logVisit(visitedUrl, numberOfItems);
});
useEffect(() => {
onNavigate(url);
}, [url]);
// ✅ url が変わった時のみログを記録
// ✅ カートのアイテム数が変わってもログは記録しない
}
3.3 カスタムフック内での使用
function useInterval(callback, delay) {
// callback を useEffectEvent でラップ
const onTick = useEffectEvent(() => {
callback();
});
useEffect(() => {
const id = setInterval(() => {
onTick();
}, delay);
return () => clearInterval(id);
}, [delay]);
// ✅ delay が変わった時のみインターバルを再設定
// ✅ callback が変わってもインターバルは再設定しない
}
// 使用例
function Counter() {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(c => c + 1);
}, 1000);
return <div>{count}</div>;
}
3.4 外部イベントシステムとの連携
function Tracker({ userId, analytics }) {
const onTrack = useEffectEvent((eventName) => {
// analytics の最新値を使用
analytics.track(eventName, { userId });
});
useEffect(() => {
window.addEventListener('click', () => onTrack('click'));
window.addEventListener('scroll', () => onTrack('scroll'));
return () => {
window.removeEventListener('click', () => onTrack('click'));
window.removeEventListener('scroll', () => onTrack('scroll'));
};
}, []);
// ✅ マウント時に一度だけイベントリスナーを登録
// ✅ userId や analytics が変わってもリスナーは再登録しない
}
4. useEffectEvent vs 通常のクロージャ
4.1 クロージャの問題: 古い値を参照してしまう
// ❌ 問題: 通常のクロージャは古い値を参照する
function Counter({ value }) {
useEffect(() => {
const handler = () => {
console.log('Value:', value); // 古い value を参照し続ける
};
window.addEventListener('click', handler);
return () => window.removeEventListener('click', handler);
}, []); // 依存配列が空なので、handler は更新されない
}
4.2 useEffectEvent の解決策: 常に最新値
// ✅ 解決: useEffectEvent は常に最新値を参照
function Counter({ value }) {
const onClick = useEffectEvent(() => {
console.log('Value:', value); // 常に最新の value
});
useEffect(() => {
window.addEventListener('click', onClick);
return () => window.removeEventListener('click', onClick);
}, []);
}
5. よくある間違いと注意点
5.1 レンダー中に呼び出す
// ❌ 間違い: レンダー中に useEffectEvent を呼び出す
function Component() {
const onSomething = useEffectEvent(() => {
console.log('something');
});
// これはエラーになる!
onSomething();
return <div />;
}
5.2 他のコンポーネントに渡す
// ❌ 間違い: 他のコンポーネントに渡す
function Parent() {
const onClick = useEffectEvent(() => {
console.log('clicked');
});
// これは推奨されない!
return <Child onClick={onClick} />;
}
5.3 依存配列を避けるためだけに使う
// ❌ 間違い: 依存配列を避けるためだけに使う
function Component({ data }) {
const processData = useEffectEvent(() => {
// data を処理
return process(data);
});
useEffect(() => {
const result = processData();
save(result);
}, []);
// ❌ data が変わっても処理が再実行されない
// これはバグの可能性が高い
}
正しい使い方:
// ✅ 正しい: data はリアクティブに、副次的な値は非リアクティブに
function Component({ data, theme }) {
const onProcess = useEffectEvent((result) => {
// theme は非リアクティブ
showNotification('Processed!', theme);
});
useEffect(() => {
const result = process(data);
save(result);
onProcess(result);
}, [data]); // data は依存配列に含める
}
6. まとめ
6.1 useEffectEvent を使うべき場面
| 場面 | 使用すべきか |
|---|---|
| エフェクト内で最新の値を読み取りたいが、再実行はしたくない | ✅ はい |
| 外部イベントシステムへのコールバック登録 | ✅ はい |
| カスタムフック内でのコールバック処理 | ✅ はい |
| 依存配列から値を除外したいだけ | ❌ いいえ |
| レンダー中に呼び出したい | ❌ いいえ |
| 他のコンポーネントに渡したい | ❌ いいえ |
6.2 実装のポイント
- エフェクト内からのみ呼び出す: レンダー中に呼び出すとエラー
- 非リアクティブなロジック専用: 値の変化に依存しないロジックのみ
- 依存配列を避けるためだけに使わない: バグが隠蔽される
- 他のコンポーネントに渡さない: 安定した参照が保証されない
-
常に最新値が見える: コミットフェーズで
ref.implが更新される