useEffectEvent フックを使って useEffect ともっと上手く付き合おう

2023/12/29に公開

useEffectEvent という react フックをご存知ですか? まだ experimental なので、知らない方も多いと思います。しかし、このフックは 「なんで今までなかったんだろう?」と思ってしまうほど革新的 です。今回はその使い方の紹介などをします。

概要: useEffectEvent は useEffect とともに使うフック

まず概要ですが、useEffectEvent は イベントリスナーを設定する useEffect とセットで使うフック です。

useEffectEvent を使うと、エフェクトとイベントリスナーを分離できます。そして、イベントリスナーの deps の変化時にエフェクトを再実行せずに済みます。

…とまあ、抽象的な説明だけでは分かりづらい と思うので、以降では useEffectEvent がどういう課題を解決するのか、また具体的にどういうケースで使われるのかなどを見ていきます。

解決したい課題: useEffect の不要な再実行を減らしたい

「特定のキーが押された時に処理を行うカスタムフック」を定義してみます。

const useKeydown = (targetKey: string, handler: () => void, isEnabled = true) => {
  useEffect(() => {
    if (!isEnabled) {
      return;
    }
    const keydownListener = (e: KeyboardEvent) => {
      if (e.key === targetKey) {
        handler();
      }
    };
    document.addEventListener('keydown', keydownListener);
    return () => {
      document.removeEventListener('keydown', keydownListener);
    };
  }, [isEnabled, targetKey, handler]);
};

一見、実装できてそうです。しかし、この実装には イベントの deps とイベントリスナーの deps が混ざってしまっている という問題があります。

  useEffect(() => {
  // ...
  }, [isEnabled, targetKey, handler]); // リスナーの deps まで入っている (targetKey と handler)

つまり、isEnabled は変わらずとも targetKey が変わればエフェクトが再実行されます。これは 実装都合で必要なだけで、本来要らないエフェクトの再実行です。

この問題を解消するのが useEffectEvent フックです。

具体的なユースケース: useEffect でイベントリスナーを設定する

useEffectEvent を使って先ほどの「特定のキーが押された時に処理を行うカスタムフック」を定義してみます。

// targetKey が押されたら handler を実行するフック
const useKeydown = (targetKey: string, handler: () => void, isEnabled = true) => {
  // イベントリスナーを定義
  const keydownListener = useEffectEvent((e: KeyboardEvent) => {
    if (e.key === targetKey) {
      handler();
    }
  });

  // keydown イベントを監視
  useEffect(() => {
    if (!isEnabled) {
      return;
    }
    document.addEventListener('keydown', keydownListener);
    return () => {
      document.removeEventListener('keydown', keydownListener);
    };
  }, [isEnabled]);
};

codesandbox で見る

こちらのコードでは、handler が変化してもエフェクトは再実行されず、さらに常に最新の handler が参照されています。

このように、useEffectEvent を使うと イベントリスナーの定義とリスナーを貼るエフェクトが分離され、エフェクトの不要な再実行がなくなります。 これが useEffectEvent の役割です。

補足: useCallback ではダメなのか?

keydownListener は useCallback でも実装できそうな雰囲気があります。

  const keydownListener = useCallback((e: KeyboardEvent) => {
    if (e.key === targetKey) {
      handler();
    }
  }, [targetKey, handler]);

しかし、これでは結局 当初の問題が解決できていません。 というのも、targetKey が変わると keydownListener が変化し、結果としてエフェクトが再実行されます。そのため、useCallback ではなく useEffectEvent でなければいけません。

(useEffectEvent の polyfill 実装には useCallback も使われています)

嬉しい点: 不要なエフェクトの再実行を減らせる

上のコード内の useEffect は isEnabled のみに依存しています。もし useEffectEvent を使わず実装していたら、targetKey など余計な依存が deps に入っていました。

  useEffect(() => {
    // ...
  }, [isEnabled]); // 余計な依存が一切ない

useEffect に余計な依存が入ってないので、このコードは「イベントリスナーは isEnabled が変化した時にだけ貼られる」というシンプルなコードになっています。余計な心配(「targetKey の変化時にもエフェクトが走るけど、実行タイミング次第でバグらないかな…」とか)が発生しないので、心理的にもとても楽です。

余計な依存がない、つまり エフェクトの不要な実行を減らせる点が useEffectEvent の最も嬉しいポイント です。

まとめ

useEffectEvent は異なる二つの関心ごと(「エフェクトをいつ実行するか」「イベントリスナーが何に依存するか」)をうまく分離してくれます。

それにより、以下のような恩恵があります

  • イベントリスナーが変化しても deps の更新が不要になる
  • 意図したタイミングでのみエフェクトが実行され、コードがシンプルになる

useEffectEvent は今後標準となるはずです。今のうちにその考え方を履修しておきましょう。

余談

useEffectEvent は元々 useEvent という名前でした。用途も微妙に異なっていましたが、そこから変更されたようです。

すでに polyfill が存在していて、概ね問題なく使えます(細かい挙動は異なります)。

参考

Discussion