React 19.2 で追加された useEffectEvent は、useEffect の依存配列まわりの悩みを解消してくれるフックです。「エフェクトをいつ走らせるか」と「エフェクト内で何をするか」を明確に分離できます。
useEffect 内で props や state を参照すると、依存配列への追加が必要です。しかし追加すると値が変わるたびにエフェクトが再実行されるため、意図しない副作用が起きやすくなります。
典型的な例として、スクロール位置の監視とアナリティクス送信を考えます。
function ArticlePage({ articleId, userId }) {
const [scrollDepth, setScrollDepth] = useState(0);
useEffect(() => {
const handleScroll = () => {
const scrollable = document.body.scrollHeight - window.innerHeight;
const depth = Math.round((window.scrollY / scrollable) * 100);
setScrollDepth(depth);
// 50% 到達したらアナリティクスに送信したい
if (depth >= 50) {
sendAnalytics({ articleId, userId, depth }); // userId を参照している
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [articleId, userId]); // userId を入れると、ログイン状態の変化でリスナーが貼り直される
}
userId をログアウト・ログインで切り替えると、スクロールリスナーが毎回解除・再設定されます。userId はスクロール監視の開始条件ではなく、送信データに含めたいだけなのに、依存配列のせいで不要な再実行が起きてしまいます。
import { useEffect, useEffectEvent, useState } from 'react';
function ArticlePage({ articleId, userId }) {
const [scrollDepth, setScrollDepth] = useState(0);
// 「送信する処理」だけを切り出す
// userId が変わっても、エフェクトの再実行には影響しない
const onReachHalf = useEffectEvent((depth) => {
sendAnalytics({ articleId, userId, depth });
});
useEffect(() => {
const handleScroll = () => {
const scrollable = document.body.scrollHeight - window.innerHeight;
const depth = Math.round((window.scrollY / scrollable) * 100);
setScrollDepth(depth);
if (depth >= 50) {
onReachHalf(depth); // 常に最新の userId を使って送信される
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [articleId]); // articleId だけ。記事が変わったときだけ貼り直す
}
userId が変わっても sendAnalytics は常に最新の値を参照します。スクロールリスナーは articleId が変わったときだけ貼り直されます。
入力内容を定期的に自動保存する機能でも同じ問題が起きます。
// Before: content が変わるたびにタイマーがリセットされる
function Editor({ docId, userSettings }) {
const [content, setContent] = useState('');
useEffect(() => {
const id = setInterval(() => {
saveDoc(docId, content, userSettings.saveFormat); // content を参照
}, 30000);
return () => clearInterval(id);
}, [docId, content, userSettings]); // content を入れるとタイマーがリセットされ続ける
}
文字を打つたびにタイマーがリセットされ、30秒が経過しないと保存されません。
// After: タイマーは docId が変わったときだけリセット
function Editor({ docId, userSettings }) {
const [content, setContent] = useState('');
const onSave = useEffectEvent(() => {
saveDoc(docId, content, userSettings.saveFormat);
// content も userSettings も常に最新を参照できる
});
useEffect(() => {
const id = setInterval(() => {
onSave();
}, 30000);
return () => clearInterval(id);
}, [docId]); // ドキュメントが変わったときだけタイマーをリセット
}
30秒ごとに確実に保存が走り、かつ保存内容は常に最新の content と userSettings を参照します。
| 項目 | 内容 |
|---|---|
| 対応バージョン | React 19.2 以上 |
| 主な用途 | エフェクト内の「反応しなくていいロジック」を切り出す |
| 最新値の参照 | 常に最新の props / state を参照可能 |
| 依存配列への追加 | 不要(追加するとエラー) |
| Lint サポート | eslint-plugin-react-hooks v6 以上が必要 |
エフェクトの再実行トリガーになるべき値まで useEffectEvent に移すと、変化に反応すべきタイミングを見逃してバグになります。
// ❌ 悪い例:docId が変わってもタイマーがリセットされない
const onSave = useEffectEvent(() => {
saveDoc(docId, content);
});
useEffect(() => {
// docId 切り替え時にクリーンアップが走らない
// → 旧ドキュメントの保存完了コールバックや状態リセットが必要な場合に問題になる
const id = setInterval(onSave, 30000);
return () => clearInterval(id);
}, []); // docId を依存配列に含めていない
useEffectEvent は常に最新の docId を参照するため、「保存先が古いドキュメントになる」ことはありません。しかし依存配列が [] のままでは、docId 切り替え時にクリーンアップ処理が走らず、旧ドキュメントに紐づく後処理(状態リセット・コールバック制御など)が必要な場面でバグになります。
「エフェクトをいつ走らせるかに関係する値」は依存配列に残し、「エフェクト内で参照するだけの値」を useEffectEvent に移すのが正しい使い方です。
useEffectEvent が解決する本質は、エフェクトの「起動条件」と「処理内容」を分けることにあります。
articleId、でも送信データには userId も必要docId、でも保存内容には最新の content が必要こういった「エフェクトのトリガーにはしたくないけど、最新値を参照したい」場面に絞って使うと、依存配列がシンプルになり、意図の伝わるコードになります。
React 19.2.0 以上を基準としています。eslint-plugin-react-hooks v6 以上への更新も合わせて推奨します。