📚

useEffectEvent関数は、useEffectの依存配列に含めてはいけない

に公開

はじめに

こちらを資料に追記したものになります。

https://speakerdeck.com/maguroalternative/react19-dot-2nouseeffecteventwozhui-u

間違った情報を記載してしまっていたので自省を込めて書いてます。
この記事も解釈間違っていたらご指摘いただけると幸いです。

https://twitter.com/sigumataityouda/status/1979173036151464031

React19.2がリリース

2025/10/1にReact19.2がリリースされました。

https://react.dev/blog/2025/10/01/react-19-2

中でも新しいhooksであるuseEffectEventが追加され、「stale closure問題」にメスが入るようになりました。

stale closure問題

簡単に言えば、「古い状態を参照し続ける」ことを指します。
例えば以下のコードがあるとします。

export function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("add listener")
    const handler = () => {
      console.log(count); // ← ここが「古い値」になる
    };
    window.addEventListener('click', handler);
    return () => window.removeEventListener('click', handler);
  }, []);
  
  return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}

内容としては「クリックされるたびにcountconsoleに表示する」ものになります。
buttonをクリックするとcountがインクリメントされます。
一見、何ら問題ないように見えますが、コンソールを覗くと、、、

はい。countが0のままです。
これはuseEffect内でのcountがイベントハンドラ登録時のものを参照し続けているためです。
この状態を「stale closure問題」と言います。

一応の解決策としては、useEffectの依存配列にcountを追加することなのですが、これはこれで別の問題があります。

export function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("add listener")
    const handler = () => {
      console.log(count); // ← ここが「古い値」になる
    };
    window.addEventListener('click', handler);
    return () => window.removeEventListener('click', handler);
  }, [count]);
  
  return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}

これをコンソールで覗くと

countは更新できましたが、新たに「add listener」の表示が毎回されるようになりました。
これはcountが更新されるたびにイベントリスナーを登録していることになります。中身のcountはその度に最新のものになりますが、わざわざそのためにイベントリスナーを再登録するのは余分です。

最新のcountを参照させたいが、いちいちイベントリスナーを再登録させたくない、、、そんな時にこそuseEffectEventの出番です。

export function Example() {
  const [count, setCount] = useState(0);

  const handlerClick = useEffectEvent(() => {
    console.log(count);
  })

  useEffect(() => {
    console.log("add listener")
    window.addEventListener('click', handlerClick);
    return () => window.removeEventListener('click', handlerClick);
  }, []);
  
  return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}

useEffectEventhandlerとして新たに定義します。
前と異なる点として、useEffectの依存配列が空になっています。
依存関係がないので、初回レンダリング時にイベントリスナーが登録されるだけになります。

そうなると先ほどと同じくcountの値は更新されないままと思いきや、、、?

はい、更新されています。
そう、useEffectEventは常に最新の状態のstateを取得してくれるのです。

useEffectEventは依存配列に含まれない?

このコードを見て、違和感を持った方もいると思います。
useEffectEventの関数をuseEffectの依存配列に含めていないのです。

実際、linter[1]も警告を出してくれてます。

なので、以下のように依存配列に含めるのが正かと思いきや、、、

useEffect(() => {
  console.log("add listener useEffectEvent")
  window.addEventListener("click", handleClick);
  return () => window.removeEventListener("click", handleClick);
}, [handleClick]);

はい、なんとイベントリスナーが再登録されてしまいます。
これはhandleClickが毎回異なる関数を返すと言う意味にもなり、場合によっては無限ループを引き起こすことにもなります。

なんでこんなことに?

以下のPRに詳細があります。

https://github.com/facebook/react/pull/25473

useEffectは外部システムと同期を行う役割を持つため、内部で使用する値や関数は依存配列に含めることが前提になっています。
linterが警告を出すのはそのためで、stale closure問題を引き起こすのを防ぐ役割を持ちます。

しかしuseEffectEventは最新の状態を持つことを保証してくれるhooksです。
常に最新の状態を持ってくれるのに、依存配列に含めることはおかしいのではないか?

そのため意図的にuseEffectEventは毎回異なる関数を返す実装になったそうです。

もうちょい補足

当初は毎回新しい関数を返す実装ではなかったようです。
しかしその場合、以下のような状態が起こりえます。

  • 依存配列あり
  const handleClick = useEffectEvent(() => {
    console.log("最新の count:", count);
  });

  useEffect(() => {
    console.log("add listener useEffectEvent")
    window.addEventListener("click", handleClick);
    return () => window.removeEventListener("click", handleClick);
  }, []);
  • 依存配列なし
  const handleClick = useEffectEvent(() => {
    console.log("最新の count:", count);
  });

  useEffect(() => {
    console.log("add listener useEffectEvent")
    window.addEventListener("click", handleClick);
    return () => window.removeEventListener("click", handleClick);
  }, [handleClick]);

この2つのコードが全く同じ挙動をすることになります。

useEffectEventの関数が外部システムなのか否かが曖昧になり、Reactの思想としてよろしくない状態になっています。

なのであえて新しい関数を返すことによって、外部システムではないことを示す実装になったらしいです。

まとめ

とりあえずこれだけ覚えておいてください。

  • useEffectEvent関数は、(useEffectの外部システムに該当しないため)useEffectの依存配列に含めてはいけない。
  • linterの更新も忘れずに、、、、
    • linterに騙されたと言ってますが、単純にlinterを更新してない自分のせいです、、、

https://twitter.com/sigumataityouda/status/1979469752784593164

脚注
  1. 最新のlinterは警告を出さないようです。 ↩︎

GitHubで編集を提案

Discussion