20251024

2025/10/24

useEffectEvent

https://ja.react.dev/reference/react/useEffectEvent
https://ja.react.dev/learn/separating-events-from-effects

React 19.2 から安定版として導入された、「useEffect 内で安定して使えるイベントハンドラ」を作るためのフック。(公式の説明としては「Effect 内の”非リアクティブな処理”を切り出すためのフック。」)

従来の方法では無駄な依存を useEffect の依存配列に増やしたり、意図した処理をさせるために useRef を駆使して手動で挙動を安定させる必要があった。

例として、「ボタンクリックでカウントを1プラスする」+「1秒ごとにカウント値をコンソールに出力する」というコードを短絡的に書いてみる。

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

  // 1秒ごとにカウント値をコンソールに出力
  useEffect(() => {
    const timerId = setInterval(() => {
      console.log(count);
    }, 1000);

    return () => {
      if (timerId) clearInterval(timerId);
    }
  }, [count]);

  return (
    <>
      <div>
        <p>{count}</p>
        <button onClick={() => setCount(count => count + 1)}>click</button>
      </div>
    </>
  )
}

export default App

リントエラーは出ないものの、このコードは期待通りに動かない。

カウント値が更新されるたびに setInterval が登録され直されるため、1秒以内にクリックし続けるとコンソールへの出力は永遠に先延ばしされる。

それを防ぐために今までは useRef を使う方法が主流だった。

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  // カウントの値を依存配列に含めないようrefに逃す
  useEffect(() => {
    countRef.current = count;
  }, [count]);

  useEffect(() => {
    // 1秒ごとにカウントの値をコンソールに出力
    const timerId = setInterval(() => {
      console.log(countRef.current);
    }, 1000);

    return () => {
      if (timerId) clearInterval(timerId);
    }
  }, []);

  return (
    <>
      <div>
        <p>{count}</p>
        <button onClick={() => setCount(count => count + 1)}>click</button>
      </div>
    </>
  )
}

カウント値用の ref を作成し、 useEffect 内の処理からはそれを参照するようにする。refオブジェクトの参照は常に同じ値となるため、リント上でも useEffect の依存配列に含めなくて良いこととなっている。

依存配列は [] になり、 setInterval はマウント時のみ実行される。コンソール出力は実行の都度、ref から現在の値を取り出して出力する。これで当初期待した「ボタンクリックでカウントを1プラスする」+「1秒ごとにカウント値をコンソールに出力する」の挙動を実現できた。

useRef を使った方法は一種のパターンとして確立されている説もあるが、 useEffectEvent を使うことでもっと直感的でわかりやすいコードにできる(個人的に useRef が出てくると脳が限界になる)。

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

  // 現在のカウント値をコンソールに出力する。
  const handler = useEffectEvent(() => {
    console.log(count);
  });

  // 1秒ごとにイベントハンドラを実行
  useEffect(() => {
    const timerId = setInterval(() => {
      handler();
    }, 1000);

    return () => {
      if (timerId) clearInterval(timerId);
    }
  }, []);

  return (
    <>
      <div>
        <p>{count}</p>
        <button onClick={() => setCount(count => count + 1)}>click</button>
      </div>
    </>
  )
}

export default App

まず useEffect 内ではカウントの値を参照する必要がなくなるため useRef を使わなくても良くなった。

useEffectEvent が返す関数は常に同じ参照先となるため、 useEffect の依存配列に含める必要もない。参照先は同じだが引数として渡す関数は常に作られ直すため、 count の値は常に最新となる。

→ 訂正(2025/11/03): useEffectEvent が返す関数は都度変わる。ただし useEffect の依存配列に含める必要はない。

さらに、

  • useEffect 内の処理は「1秒ごとに実行されるイベントハンドラを登録する」
  • useEffectEvent 内の処理は「現在のカウント値をコンソールに出力する」

というようにそれぞれ責務を分担させることで、簡潔で見通しの良いコードにできるというメリットもある。

一方制約もあり、 useEffectEvent で生成した関数は useEffectuseLayoutEffectuseInsertionEffect 内からしか呼び出してはならない。子コンポーネントに props として渡すのもダメ。

ちなみに「なんか useCallback でも同じことできそうじゃない?」と思いがちだが、 useCallback を使った場合、その関数を useEffect に含めなくてはならなくなるため、また useRef を使う必要が出てくる。

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

  // カウント値をコンソールに出力する
  const handler = useCallback(() => {
    console.log(count);
  }, [count]);

  const handlerRef = useRef(handler);

  // イベントハンドラを依存配列に含めないようrefに逃す
  useEffect(() => {
    handlerRef.current = handler;
  }, [handler]);

  // 1秒ごとにイベントハンドラを実行
  useEffect(() => {
    const timerId = setInterval(() => {
      handlerRef.current();
    }, 1000);

    return () => {
      if (timerId) clearInterval(timerId);
    }
  }, []);

  return (
    <>
      <div>
        <p>{count}</p>
        <button onClick={() => setCount(count => count + 1)}>click</button>
      </div>
    </>
  )
}

export default App

責務の分担はできているもののコードとしては冗長になってしまう。