はじめに
最近、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
を使わない場合、意図通りに動作させるためには、以下のようにuseRef
やuseInsertionEffect
などを使って対応する必要がありそうです:
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]); }
この実装の問題点はuseEffect
のdependencies
として指定されている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
)の外で呼んではいけません (例: レンダリングフェーズなどにおいて呼ぶことはできません) useEffect
のdependencies
からは省略する必要があります- 他のコンポーネントのpropsなどに指定することはできません
useEffectEvent
を使うことで意図せず何度もcallback
が実行されてしまうことを防止できます。上記のコードの場合、callback
はReact RouterのLocation
オブジェクトが変更されたタイミングでのみ呼ばれます。
先ほどのuseRef
などを使った方法と比較して、useEffectEvent
を使うことによって、より直感的にやりたいことが実現できます。
useEffectEvent
とは
先ほど使い方を紹介したuseEffectEvent
について、公式ドキュメントを参考により詳しく見ていきます。
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で処理しています。
この実装には一つ問題があります。handleMessage
はuseEffect
のdependencies
に指定されているため、この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
という名前へリネームされたようです)
現状ではどうしたらいいか?
useEffectEvent
は便利ではありますが、現在はまだ実験的APIのため、Reactの安定バージョンにおいては利用することができません。
調べてみたところ、いくつかのOSSにおいて自前でpolyfillを実装しているようです:
- Superset
useEffectEvent
が安定化された際の移行を容易にするために、use-event-callback
パッケージをベースにuseEffectEvent
を実装しているようです (apache/superset#23871)
- Bluesky
useInsertionEffect
+useRef
+useCallback
をベースにuseNonReactiveCallback
というカスタムフックを実装しているようです (src/lib/hooks/useNonReactiveCallback.ts)
- Radix UIのWebサイト
- Blueskyとほぼ同様ですが、こちらはレンダリングフェースで実行された際に例外が発生する対応が行われています (utils/use-effect-event.ts)
これらを参照することで、useEffectEvent
を使うべき場面の参考にもなりそうです。
ただ、紹介をしておいてアレですが、現状ではpolyfillなどは用意せずに、useRef
を使った解決策で対処しておくのが無難ではないかと個人的には思っています。
便利なAPIではあるものの、現状ではまだuseEffectEvent
は実験的APIであり、正式に導入されるかどうかはわかりません。今後、引数や戻り値の形式などが変更される可能性も考えられます。
また、もしuseEffectEvent
が正式に導入された場合、公式でマイグレーションガイドの公開やeslint-plugin-react-compiler
やeslint-plugin-react-hooks
から移行のためのルールの提供なども考えられるため、それらの提供を待った方がスムーズに移行できる可能性も考えられそうです。現状では、まだuseRef
を使った解決策で様子をみておいたほうが安全ではないかと考えています。
おわりに
以上、useEffectEvent
の紹介でした。とても便利なAPIなので、今後のバージョンで導入されることを楽しみにしています!