この記事は ノバセル 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開発における実践的なスキル向上に役立てていただければ幸いです。