RevComm Tech Blog

コミュニケーションを再発明し 人が人を想う社会を創る

ReactのuseEffectEventの紹介と現状での代替手段について

はじめに

最近、ReactにuseEffectEventという実験的APIが存在することを知りました。

弊社で提供しているMiiTel Phoneにおいては、WebSocketやWebRTCなどによってさまざまなタイミングや箇所で非同期的にイベントが発生します。

その関係もあってuseEffectを広く活用しているのですが、そういった処理をこのuseEffectEventを使うことによって単純化できるのではないかと思い、調べてみることにしました。

注意

useEffectEventは現状では実験的APIです。安定版のReactでは利用することができません。もし試してみたい場合は、下記パッケージのexperimentalバージョンが必要です:

  • react@experimental
  • react-dom@experimental
  • eslint-plugin-react-hooks@experimental

活用例

まずuseEffectEventの活用例として、React RouterがLocationの変更を検知するたびに特定のコールバック関数を実行するようなケースを元に考えてみます。

useEffectEventを使わない場合

useEffectEventを使わない場合、意図通りに動作させるためには、以下のようにuseRefuseInsertionEffectなどを使って対応する必要がありそうです:

import {
  useEffect,
  useInsertionEffect,
  useRef
} from 'react';
import { useLocation } from 'react-router-dom';

export function useOnLocationChanged(callback) {
  const refCallback = useRef();
  const location = useLocation();

  useInsertionEffect(() => {
    refCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    if (refCallback.current) {
      refCallback.current(location);
    }
  }, [location]);
}

なぜこのようにuseRefなどを使う必要があるのでしょうか?例えば、useOnLocationChangedが以下のように実装されていたとします:

import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';

export function useOnLocationChanged(callback) {
  const location = useLocation();

  useEffect(() => {
    callback(location);
  }, [callback, location]);
}

この実装の問題点はuseEffectdependenciesとして指定されているcallback関数の参照が変わるたびに、Locationが変更されていないにも関わらず意図せずcallbackが呼ばれてしまう点です:

function SomeComponent(props) {
  useOnLocationChanged(() => {
    // SomeComponentが再レンダリングされるたびに、意図せずこのcallbackが呼ばれてしまいます
    // ...
  });
  // ...
}

この問題を防ぐためには、現状ではuseRefを活用するなどの工夫が必要です。

useEffectEventを使う場合

先ほどの処理はuseEffectEventを使うと簡略化することができます。

import { experimental_useEffectEvent as useEffectEvent } from 'react';

function useOnLocationChanged(callback) {
  const location = useLocation();
  const onLocationChanged = useEffectEvent(callback);

  useEffect(() => {
    onLocationChanged(location);
  }, [location]);
}

useEffectEventは引数として関数を受け取り、関数を返却します (onLocationChanged)

このuseEffectEventから返却された関数には以下のようなルールがあります:

  • Effect (useEffect)の外で呼んではいけません (例: レンダリングフェーズなどにおいて呼ぶことはできません)
  • useEffectdependenciesからは省略する必要があります
  • 他のコンポーネントのpropsなどに指定することはできません

useEffectEventを使うことで意図せず何度もcallbackが実行されてしまうことを防止できます。上記のコードの場合、callbackはReact RouterのLocationオブジェクトが変更されたタイミングでのみ呼ばれます。

先ほどのuseRefなどを使った方法と比較して、useEffectEventを使うことによって、より直感的にやりたいことが実現できます。

useEffectEventとは

先ほど使い方を紹介したuseEffectEventについて、公式ドキュメントを参考により詳しく見ていきます。

react.dev github.com

React の公式ドキュメントでは以下の3つをreactiveな値と定義しています:

  • Props
  • State
  • コンポーネント関数直下で定義された変数

そして、Reactには副作用を取り扱う方法として以下の手段があります:

  • Effect - reactiveな値の変更時に実行されるreactiveな処理
  • イベントハンドラー - ユーザーの操作によって実行される非reactiveな処理

useEffectEventはこれら2つの中間に相当するもので、Effect内で非reactiveな処理を実行したい場合に使用することが想定されます。

WebSocketを使ってリアルタイムにメッセージのやり取りを行う際のケースを例に見ていきます。

function ChannelView({ channelID }) {
  const logger = useLogger();
  const dispatch = useDispatch();
  const handleMessage = useCallback((message) => {
    logger.info(`received: ${message}`);
    dispatch(appendMessage(channelID, message));
  }, [channelID, dispatch, logger]);
  useEffect(() => {
    const ws = new WebSocket(`/channels/${channelID}`);
    ws.addEventListener('message', (e) => handleMessage(e.data));
    return () => ws.close();
  }, [handleMessage, channelID]);
  
  // 省略...
}

props.channelIDの変更時に新しいWebSocket接続を張る処理は、ユーザーの操作ではなく reactiveな値の変更に基づいて実行する処理であるためEffectで処理しています。

この実装には一つ問題があります。handleMessageuseEffectdependenciesに指定されているため、このhandleMessage変数が参照する関数が変更されるたびにEffectが再実行され、WebSocketのコネクションが一から貼り直されてしまいます。これは意図せぬ挙動です。

handleMessage内の処理は、Effectが依存しているreactiveな値 (props.channelID)が変更されたタイミングで実行されるものではなく、WebSocketからmessageを受信したタイミングで実行される処理です(非reactiveな処理)

useEffectEventによってこういった非reactiveな処理をEffectから抽出することができ、意図せぬタイミングで何度もEffectが実行されてしまう事態を防止できます。

function ChannelView({ url }) {
  const logger = useLogger();
  const dispatch = useDispatch();
  const handleMessage = useEffectEvent((message) => {
    logger.info(`received: ${message}`);
    dispatch(appendMessage(channelID, message));
  });
  useEffect(() => {
    const ws = new WebSocket(`/channels/${channelID}`);
    ws.addEventListener('message', (e) => handleMessage(e.data));
    return () => ws.close();
  }, [url]);
  
  // ...
}

補足ですが、RFCによるとuseEffectEventから返却される関数はonまたはhandleから始まる名前の変数に格納されることが想定されているようです (元々、RFCにおいてはuseEventという名前で提案されていたようですが、後ほど現在のuseEffectEventという名前へリネームされたようです)

github.com github.com

現状ではどうしたらいいか?

useEffectEventは便利ではありますが、現在はまだ実験的APIのため、Reactの安定バージョンにおいては利用することができません。

調べてみたところ、いくつかのOSSにおいて自前でpolyfillを実装しているようです:

  • Superset
    • useEffectEventが安定化された際の移行を容易にするために、use-event-callback パッケージをベースにuseEffectEventを実装しているようです (apache/superset#23871)
  • Bluesky
  • Radix UIのWebサイト
    • Blueskyとほぼ同様ですが、こちらはレンダリングフェースで実行された際に例外が発生する対応が行われています (utils/use-effect-event.ts)

これらを参照することで、useEffectEventを使うべき場面の参考にもなりそうです。

ただ、紹介をしておいてアレですが、現状ではpolyfillなどは用意せずに、useRefを使った解決策で対処しておくのが無難ではないかと個人的には思っています。

便利なAPIではあるものの、現状ではまだuseEffectEventは実験的APIであり、正式に導入されるかどうかはわかりません。今後、引数や戻り値の形式などが変更される可能性も考えられます。

また、もしuseEffectEventが正式に導入された場合、公式でマイグレーションガイドの公開やeslint-plugin-react-compilereslint-plugin-react-hooksから移行のためのルールの提供なども考えられるため、それらの提供を待った方がスムーズに移行できる可能性も考えられそうです。現状では、まだuseRefを使った解決策で様子をみておいたほうが安全ではないかと考えています。

おわりに

以上、useEffectEventの紹介でした。とても便利なAPIなので、今後のバージョンで導入されることを楽しみにしています!