
この記事は ノバセル Advent Calendar 18 日目です。
ノバセル新卒3年目の田村(tamtam)です。最近ではJapanglish Techの主催をしています。
この記事では、React Hooksの一つである useClickAway フックについて深掘りし、コールバック関数の最新化をイベントリスナーの更新から分離させる「最新の Ref パターン」について詳しく解説します。これにより、stale closure(古い値の参照)の回避など、効果的な利点について学びましょう。
1. useClickAwayとは
useClickAwayフックは、react-useで提供されるReact Hooksの1つです。
指定した要素の外側でクリックやタッチイベントが発生した際に特定のコールバックを実行するために設計されています。
主にドロップダウンメニュー、モーダル、ツールチップなどを閉じる際に利用されます。このフックの実装を確認し、その背後にある設計思想と利点を理解しましょう。
useClickAwayの動作の説明
以下は、useClickAway のコードです
import { useEffect, useRef } from 'react'; function useClickAway(ref, onClickAway, events = ['mousedown', 'touchstart']) { const savedCallback = useRef(onClickAway); useEffect(() => { savedCallback.current = onClickAway; }, [onClickAway]); useEffect(() => { const handler = (event) => { const { current: el } = ref; if (el && !el.contains(event.target)) { savedCallback.current(event); } }; for (const eventName of events) { document.addEventListener(eventName, handler); } return () => { for (const eventName of events) { document.removeEventListener(eventName, handler); } }; }, [events, ref]); } export default useClickAway;
詳細な動作説明
savedCallbackの初期化:初回レンダリング時に、
savedCallback.currentにonClickAwayが設定されます。useRefを使用することで、この参照は再レンダリング前後で保持されます。useEffectによるsavedCallbackの更新:onClickAwayが変更されるたびに、このuseEffectが実行され、savedCallback.currentが最新のonClickAwayに更新されます。これにより、イベントリスナー内で常に最新のコールバックが呼び出されます。イベントリスナーの設定 (
useEffectの第二のフック):eventsやrefに変更があった場合にのみ実行されます。ref は、監視対象となるDOM要素への参照を保持するために使用されます。useClickAway フックに渡す ref は、外部クリックを検知したい要素を指し示す必要があります。
events は、どのイベントを監視するかを指定する配列です。デフォルトでは ['mousedown', 'touchstart'] が設定されていますが、必要に応じてカスタマイズ可能(例: keydown)です。
このフック内で定義された
handler関数は、一度だけ定義され、指定されたイベントに対して登録されます。handler関数内では、常にsavedCallback.current(最新のonClickAway)が呼び出されます。再レンダリングとの関係:
savedCallback.currentの更新はuseRefを介して行われるため、savedCallback.currentの変更自体は再レンダリングを引き起こしません。 イベントリスナーの設定・解除も、useEffectの依存配列にonClickAwayを含めていないため、onClickAwayの変更によって再登録されることはありません。
2. useClickAwayで利用されている「最新の Ref パターン」について
useClickAway の実装において、useRef を活用した「最新の Ref パターン」が採用されています。このパターンの目的とその必要性について詳しく見ていきましょう。
i.再レンダリングを防ぐことによるパフォーマンスの向上
useClickAway では、イベントリスナーを設定する際にコールバック関数を直接渡すのではなく、useRef を用いて最新のコールバックを保持しています。
これにより、コールバック関数が変更されても、イベントリスナーは一度だけ設定され、savedCallback.current を通じて最新のコールバックが呼び出されます。
結果として、毎回イベントリスナーを削除して再登録する手間が省け、パフォーマンスが向上します。
ii.stale closure(古い値の参照)によるバグを防ぐ
「最新の Ref パターン」を使用する最大の利点は、stale closureによるバグを防ぐことです。stale valueについては次のセクションで詳しく解説します。
軽く説明すると、useRef を用いることで、非同期コールバック内でも常に最新のステートやプロパティにアクセスできるため、古い値を参照してしまう問題を回避できます。
最新のコールバックへのアクセス:
savedCallback.currentを介して最新のコールバック関数にアクセスすることで、非同期イベントが発生した際にも、最新のステートやプロパティを反映した処理を実行できます。クロージャによる古い値の回避:
非同期コールバックが古いレンダー時点のステートをキャプチャする問題を、
useRefを利用することで解消します。これにより、常に最新の値を参照でき、バグの発生を防止します。
補足: useClickAway における Stale Closure の具体例について
useClickAway のユースケースにおいて、「stale closure」によるバグが発生する具体的なケースは、実際にはあまり一般的ではありません。
これは、useClickAway が主に外部クリックを検知して特定のコールバックを実行するため、コールバック自体が頻繁にステートに依存するような状況が少ないためです。
しかし、理論的には以下のような状況で useClickAway においても stale closure の問題が発生する可能性があります:
動的なコールバック関数:
useClickAwayに渡すコールバック関数が、コンポーネントのステートやプロパティに依存しており、そのステートが更新されるたびに新しいコールバックが必要な場合です。
例えば、外部クリック時に特定のステートを更新するような複雑なロジックを持つコールバック関数です。
非同期処理との組み合わせ:
コールバック関数内で非同期処理(例えば、API呼び出しや
setTimeout)を行う場合、レンダー後にステートが更新されても、非同期処理内で古いステートが参照される可能性があります。
3. stale closure(古い値の参照)の回避の深掘り
React の関数コンポーネントにおいて、非同期コールバック(例えば、setTimeout やイベントリスナー内の関数)内でステートやプロパティを参照すると、古いレンダー時の値(stale value)を参照することがあります。
これがなぜ起こるのか、そしてそれがどのようなバグを引き起こす可能性があるのかについて詳しく解説します。
a. stale valueとは何か?
stale value とは、最新のステートやプロパティではなく、以前のレンダー時点の値を指します。
React の関数コンポーネントでは、コンポーネントが再レンダリングされるたびに、関数が再評価され、新しいステートやプロパティの値が反映されます。
しかし、非同期コールバック内では、以前のレンダー時点の値を閉じ込める(キャプチャする)ことがあります。
b. 非同期コールバック内でstale valueが発生する理由
i. クロージャの性質
JavaScript のクロージャは、関数が定義されたスコープ内の変数への参照を保持します。
React の関数コンポーネントにおいて、各レンダーは独自のスコープを持つため、非同期コールバックはそのレンダー時点のステートやプロパティにアクセスします。
ii. 非同期性によるタイミングのズレ
setTimeout やイベントリスナーのコールバックは、非同期に実行されるため、関数コンポーネントのレンダー後に呼び出されます。
このタイミングのズレにより、コールバック内で参照されるステートやプロパティが、コールバックが設定された時点のものとなり、後から更新された最新の値を反映できません。
4. コード例での理解
以下のコード例を見てみましょう:
引用:Hooks FAQ - Why am I seeing stale props or state inside my function?
import React, { useState } from 'react'; function Example() { const [count, setCount] = useState(0); function handleAlertClick() { setTimeout(() => { alert('You clicked on: ' + count); }, 3000); } return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> <button onClick={handleAlertClick}> Show alert </button> </div> ); } export default Example;
注意: このコードはあくまでもstale valueの説明であり、useClickAway のレンダリング最小化とは別の話です。useState を使用しているため、レンダリングの最小化は実現できません。
動作の流れ
- 初期レンダー:
countは0に設定されています。- 「Show alert」ボタンをクリックすると、3秒後に「You clicked on: 0」というアラートが表示されます。
- アラート表示前にカウントを増やす:
- 「Show alert」をクリックし、その後すぐに「Click me」を複数回クリックして
countを増やします。 - しかし、アラートは「You clicked on: 0」のまま表示されます。
- 「Show alert」をクリックし、その後すぐに「Click me」を複数回クリックして
なぜアラートが古い値を表示するのか?
handleAlertClick 関数内の setTimeout のコールバックは、handleAlertClick が呼び出された時点の count の値を閉じ込めています。
そのため、非同期に実行されるコールバックは、count が更新されても最初に閉じ込められた 0 の値を表示します。
5. stale valueによるバグの例
stale valueを参照することは、以下のような予期せぬバグを引き起こすことがあります。
a. フォームの送信
ユーザーがフォームを入力し、非同期でデータを送信する場合、送信時に古いステートを参照すると、最新の入力内容を反映しないことがあります。
これにより、ユーザーの意図しないデータを送信するリスクがあります。
b. リアルタイムフィードバック
リアルタイムでフィードバックを提供する機能(例えば、検索フィルターやリアルタイムチャット)において、古いステートを参照すると、最新のユーザー入力に基づいたフィードバックが提供されなくなります。
これにより、ユーザー体験を損なう可能性があります。
6. stale valueのバグを防ぐ方法
stale valueを防ぐためには、以下のような方法があります。
useRef を使用する「最新の Ref パターン」
useRef を使って最新のコールバックやステートを保持し、非同期コールバック内で参照します。これにより、常に最新の値にアクセスできます。
実装例
import React, { useState, useRef, useEffect } from 'react'; function Example() { const [count, setCount] = useState(0); const countRef = useRef(count); useEffect(() => { countRef.current = count; }, [count]); function handleAlertClick() { setTimeout(() => { alert('You clicked on: ' + countRef.current); }, 3000); } return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> <button onClick={handleAlertClick}> Show alert </button> </div> ); } export default Example;
解説
useRefの利用:countRefはuseRefを用いて作成され、初期値としてcountを保持します。useEffectでcountRefを更新:countが変更されるたびに、countRef.currentを最新のcountに更新します。これにより、非同期コールバック内でも常に最新のcountを参照できます。非同期コールバック内で
countRef.currentを参照:setTimeoutのコールバック内でcountRef.currentを参照することで、最新のcount値を取得できます。
この方法の利点:
stale valueの回避:
非同期コールバックが常に最新の
countを参照するため、古い値を表示する問題を防げます。再レンダリングの最小化:
useRefの更新は再レンダリングを引き起こさないため、パフォーマンスに優れています。
カスタムフックの利用: useLatest
「最新の Ref パターン」を応用したカスタムフックであるuseLatestを利用することで、コードの再利用性と可読性を向上させることができます。
useLatest フックの実装
import { useRef } from 'react'; const useLatest = <T>(value: T): { readonly current: T } => { const ref = useRef(value); ref.current = value; return ref; }; export default useLatest;
使用例
import React, { useState } from 'react'; import useLatest from './useLatest'; function Example() { const [count, setCount] = useState(0); const latestCount = useLatest(count); function handleAlertClick() { setTimeout(() => { alert('You clicked on: ' + latestCount.current); }, 3000); } return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> <button onClick={handleAlertClick}> Show alert </button> </div> ); } export default Example;
解説
useLatestフック:任意の値を受け取り、その最新の値を
refに保持します。useRefを使用してrefを作成し、毎回レンダリング時にref.currentに最新の値を直接代入することで、最新の値を保持します。非同期コールバック内での利用:
latestCount.currentを参照することで、最新のcountを取得できます。これにより、非同期コールバック内でもstale valueを避けることができます。
この方法の利点:
コードの再利用性:
useLatestを他のコンポーネントでも簡単に利用でき、同様の問題を解決できます。可読性の向上:
コールバック内で直接
refを操作する必要がなくなり、コードがシンプルになります。
7. まとめ
React Hooksを活用することで、関数コンポーネント内で強力かつ柔軟なロジックを実装できます。
特に、useClickAway フックと「最新の Ref パターン」を組み合わせることで、イベントリスナーの管理を効率化し、stale valueによるバグを防ぐことが可能です。
以下に、今回解説したポイントをまとめます。
useClickAway フック:
指定した要素の外側でのクリックやタッチイベントを検知し、特定のコールバックを実行します。主にドロップダウンメニューやモーダルの閉鎖に利用されます。
最新の Ref パターン:
useRefを用いて最新のコールバックやステートを保持し、非同期コールバック内で参照します。これにより、stale valueによるバグを防ぎつつ、イベントリスナーの再登録を最小限に抑えることができます。stale valueの理解と回避:
stale valueとは、非同期コールバック内で古いレンダー時点のステートやプロパティを参照してしまう問題です。
useRefによる最新のRefパターンやuseLatestを活用することで、この問題を効果的に回避できます。
この記事を通じて、useClickAway フックと最新の Ref パターンについての理解が深まり、React開発における実践的なスキル向上に役立てていただければ幸いです。