React コールバック refs: それらが何であり、どのように使用するか

開発中、DOM要素との直接的なやり取りが必要なことがよくあります。そのような場合、Reactはrefsと呼ばれるメカニズムを提供しており、レンダリング後に要素にアクセスすることができます。一般的には、標準のオブジェクトrefsをuseRef(それで呼びましょう)を使用しますが、callback refsとして知られる別のアプローチもあります。この方法は、要素のライフサイクルに関する柔軟性と制御を提供し、要素がDOMにアタッチされたりデタッチされたりする正確なタイミングで特定のアクションを実行できるようにします。

この記事では、callback refsとは何か、どのように機能するか、遭遇するかもしれない落とし穴を説明し、使用例を示したいと思います。

Callback Refsとは何か、どのように機能するか

Callback refsは、オブジェクトrefsと比較して参照のアタッチメントに対してより細かい制御を提供します。実際にどのように機能するかを見てみましょう。

  1. マウント。要素がDOMにマウントされると、ReactはDOM要素自体を引数としてref関数を呼び出します。これにより、ページに要素が表示された直後にその要素でアクションを実行できます。
  2. アンマウント。要素がアンマウントされると、Reactはref関数をnullで呼び出します。これにより、その要素に関連付けられたアクションをクリーンアップしたりキャンセルしたりする機会が得られます。

例:マウントとアンマウントのトラッキング

TypeScript

 

import React, { useCallback, useState } from 'react';

function MountUnmountTracker() {
  const [isVisible, setIsVisible] = useState(false);

  const handleRef = useCallback((node: HTMLDivElement | null) => {
    if (node) {
      console.log('Element mounted:', node);
    } else {
      console.log('Element unmounted');
    }
  }, []);

  return (
    <div>
      <button onClick={() => setIsVisible((prev) => !prev)}>
        {isVisible ? 'Hide' : 'Show'} element
      </button>
      {isVisible && <div ref={handleRef}>Tracked element</div>}
    </div>
  );
}

export default MountUnmountTracker;

要素の表示/非表示を切り替えるたびに、handleRef 関数がノードまたは null とともに呼び出され、要素がアタッチまたはデタッチされた瞬間を追跡できます。

一般的な問題と解決策

問題: コールバック Ref の繰り返し呼び出し

コンポーネントの再レンダリングごとにコールバック Ref を繰り返し作成するという頻繁な問題があります。これにより、React は新しい ref だと認識し、古い ref に null(クリーンアップ)を呼び出し、その後新しいものを初期化します — 実際には要素やコンポーネントが変更されていなくてもです。これにより、望ましくない副作用が発生する可能性があります。

問題の例

Basic コンポーネントを考えてみましょう。このコンポーネントには、コールバック Ref を持つ div の表示/非表示を切り替えるためのボタンと、コンポーネントを再レンダリングするための別のボタンがあります。

TypeScript

 

import React, { useState, useReducer } from 'react';

function Basic() {
  const [showDiv, setShowDiv] = useState(false);
  const [, forceRerender] = useReducer((v) => v + 1, 0);

  const toggleDiv = () => setShowDiv((prev) => !prev);

  const refCallback = (node: HTMLDivElement | null) => {
    console.log('div', node);
  };

  return (
    <div>
      <button onClick={toggleDiv}>Toggle Div</button>
      <button onClick={forceRerender}>Rerender</button>
      {showDiv && <div ref={refCallback}>Example div</div>}
    </div>
  );
}

export default Basic;

Rerender ボタンをクリックするたびに、コンポーネントが再レンダリングされ、新しい refCallback 関数が作成されます。その結果、React は古い refCallback(null) を呼び出し、次に新しい refCallback(node) を呼び出しますが、要素の ref が変更されていない場合でもです。コンソールでは、div nulldiv [node] が交互に表示されます。明らかに、そのような不必要な呼び出しを避けたいと思います。

解決策: useCallback を使用してコールバック Ref をメモ化する

これを回避するのは非常に簡単です: 単に useCallback を使用して関数をメモ化します。そのようにすることで、依存関係が変更されない限り、関数は再レンダリングをまたいで変更されずに保持されます。

TypeScript

 

import React, { useState, useCallback, useReducer } from 'react';

function Basic() {
  const [showDiv, setShowDiv] = useState(false);
  const [, forceRerender] = useReducer((v) => v + 1, 0);

  const toggleDiv = () => setShowDiv((prev) => !prev);

  const refCallback = useCallback((node: HTMLDivElement | null) => {
    console.log('div', node);
  }, []);

  return (
    <div>
      <button onClick={toggleDiv}>Toggle Div</button>
      <button onClick={forceRerender}>Rerender</button>
      {showDiv && <div ref={refCallback}>Example div</div>}
    </div>
  );
}

export default Basic;

今、refCallback は初回描画時に一度だけ作成されます。再レンダリング時に余分な呼び出しが発生せず、不必要なコールバックを防いでパフォーマンスを向上させます。

コールバックリファレンス、useLayoutEffect、useEffectの順序

特定の問題を解決するためにコード内でコールバックリファレンスを使用する方法について説明する前に、useEffectuseLayoutEffect フックとコールバックリファレンスがどのように相互作用するかを理解し、リソースの初期化とクリーンアップを適切に整理できるようにしましょう。

実行順序

  1. コールバックリファレンス – DOM要素の描画直後に呼び出され、エフェクトフックが実行される前に
  2. useLayoutEffect – すべてのDOM変更の後に実行されますが、ブラウザが描画する前に
  3. useEffect – コンポーネントの画面へのレンダリングが完了した後に実行されます
TypeScript

 

import React, { useEffect, useLayoutEffect, useCallback } from 'react';

function WhenCalled() {
  const refCallback = useCallback((node: HTMLDivElement | null) => {
    if (node) {
      console.log('Callback ref called for div:', node);
    } else {
      console.log('Callback ref detached div');
    }
  }, []);

  useLayoutEffect(() => {
    console.log('useLayoutEffect called');
  }, []);

  useEffect(() => {
    console.log('useEffect called');
  }, []);

  return (
    <div>
      <div ref={refCallback}>Element to watch</div>
    </div>
  );
}

export default WhenCalled;

コンソール出力

  1. divのコールバックリファレンスが呼び出されました:[div要素]
  2. useLayoutEffectが呼び出されました
  3. useEffectが呼び出されました

このシーケンスから、コールバックリファレンスはuseLayoutEffectuseEffect などのフックよりも前にトリガーされることがわかります。これは、ロジックを書く際に心に留めておくと重要です。

コード内でコールバックリファレンスが解決する問題は何ですか?

まず、通常のオブジェクトリファレンスで通常遭遇する問題を再現して、次にコールバックリファレンスを使用して解決します。

TypeScript

 

import { useCallback, useEffect, useRef, useState } from 'react';

interface ResizeObserverOptions {
  elemRef: React.RefObject<HTMLElement>;
  onResize: ResizeObserverCallback;
}

function useResizeObserver({ elemRef, onResize }: ResizeObserverOptions) {
  useEffect(() => {
    const element = elemRef.current;

    if (!element) {
      return;
    }

    const resizeObserver = new ResizeObserver(onResize);

    resizeObserver.observe(element);

    return () => {
      resizeObserver.unobserve(element);
    };
  }, [onResize, elemRef]);
}

export function UsageDom() {
  const [bool, setBool] = useState(false);
  const elemRef = useRef<HTMLDivElement>(null);

  const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
    console.log('resize', entries);
  }, []);

  useResizeObserver({ elemRef, onResize: handleResize });

  const renderTestText = () => {
    if (bool) {
      return <p ref={elemRef}>Test text</p>;
    }

    return <div ref={elemRef}>Test div</div>;
  };

  return (
    <div style={{ width: '100%', textAlign: 'center' }}>
      <button onClick={() => setBool((v) => !v)}>Toggle</button>
      {renderTestText()}
    </div>
  );
}

ここではすべての詳細に立ち入りません。要するに、私たちはResizeObserverを介してdivまたはp要素のサイズを追跡しています。最初はすべてうまくいきます:マウント時に要素のサイズを取得でき、リサイズもコンソールに報告されます。

本当の問題は、観測している要素を切り替えるために状態を切り替えるときに始まります。状態を変更し、したがって追跡されている要素を置換すると、私たちのResizeObserverは正しく機能しなくなります。最初の要素を観測し続け、それは既にDOMから削除されています!元の要素に切り替えても、新しい要素への購読が適切にアタッチされないため、役立ちません。

注意:次の解決策は、ユニバーサルコードが必要なライブラリコンテキストで書く可能性があるものよりも、より代表的です。実際のプロジェクトでは、フラグ、エフェクトなどの組み合わせを使って問題を解決するかもしれません。しかし、ライブラリコードでは、特定のコンポーネントやその状態についての知識がありません。このような状況では、コールバックrefsが私たちを助けることができます。

TypeScript

 

import { useCallback, useRef, useState } from 'react';

function useResizeObserver(onResize: ResizeObserverCallback) {
  const roRef = useRef<ResizeObserver | null>(null);

  const attachResizeObserver = useCallback(
    (element: HTMLElement) => {
      const resizeObserver = new ResizeObserver(onResize);
      resizeObserver.observe(element);
      roRef.current = resizeObserver;
    },
    [onResize]
  );

  const detachResizeObserver = useCallback(() => {
    roRef.current?.disconnect();
  }, []);

  const refCb = useCallback(
    (element: HTMLElement | null) => {
      if (element) {
        attachResizeObserver(element);
      } else {
        detachResizeObserver();
      }
    },
    [attachResizeObserver, detachResizeObserver]
  );

  return refCb;
}

export default function App() {
  const [bool, setBool] = useState(false);

  const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
    console.log('resize', entries);
  }, []);

  const resizeRef = useResizeObserver(handleResize);

  const renderTestText = () => {
    if (bool) {
      return <p ref={resizeRef}>Test text</p>;
    }

    return <div ref={resizeRef}>Test div</div>;
  };

  return (
    <div style={{ width: '100%', textAlign: 'center' }}>
      <button onClick={() => setBool((v) => !v)}>Toggle</button>
      {renderTestText()}
    </div>
  );
}

ご覧の通り、useResizeObserverフックをコールバックrefを使用するように書き直しました。リサイズ時に発火する(メモ化された)コールバックを渡すだけで、要素を何度切り替えても、リサイズコールバックは依然として動作します。それは、コールバックrefのおかげで、オブザーバが新しい要素にアタッチし、古い要素から正確なタイミングでデタッチするからです。

ここでの主な利点は、私たちのフックを使用する開発者が、観察者の追加/削除のロジックを気にする必要がなくなることです – そのロジックをフック内にカプセル化しています。開発者は、単にコールバックをフックに渡し、その返されたrefを要素にアタッチするだけです。

複数のRefを1つに結合する

もう1つのシナリオでは、コールバックrefが救世主となります:

TypeScript

 

import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';

interface InputProps {
  value?: string;
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
}

const Input = forwardRef(function Input(
  props: InputProps,
  ref: React.ForwardedRef<HTMLInputElement>
) {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    if (!inputRef.current) {
      return;
    }

    console.log(inputRef.current.getBoundingClientRect());
  }, []);

  return <input {...props} ref={ref} />;
});

export function UsageWithoutCombine() {
  const inputRef = useRef<HTMLInputElement | null>(null);

  const focus = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <Input ref={inputRef} />
      <button onClick={focus}>Focus</button>
    </div>
  );
}

上記のコードでは、プロップスからforwardRefを使用して取得した単純な入力コンポーネントにリファレンスを設定しています。しかし、Inputコンポーネント内でinputRefをどのように使用するのでしょうか?フォーカスなどの外部からのリファレンスも必要な場合。入力コンポーネント自体でgetBoundingClientRectなどを行いたいかもしれません。プロップリファレンスを内部リファレンスに置き換えると、外部からのフォーカスが機能しなくなります。そのため、これら2つのリファレンスを組み合わせるにはどうすればよいのでしょうか?

ここで再びコールバックrefが役立ちます:

TypeScript

 

import { useEffect, useRef } from 'react';
import { forwardRef, useCallback } from 'react';

type RefItem<T> =
  | ((element: T | null) => void)
  | React.MutableRefObject<T | null>
  | null
  | undefined;

function useCombinedRef<T>(...refs: RefItem<T>[]) {
  const refCb = useCallback((element: T | null) => {
    refs.forEach((ref) => {
      if (!ref) {
        return;
      }

      if (typeof ref === 'function') {
        ref(element);
      } else {
        ref.current = element;
      }
    });
  }, refs);

  return refCb;
}

interface InputProps {
  value?: string;
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
}

const Input = forwardRef(function Input(
  props: InputProps,
  ref: React.ForwardedRef<HTMLInputElement>
) {
  const inputRef = useRef<HTMLInputElement>(null);
  const combinedInputRef = useCombinedRef(ref, inputRef);

  useEffect(() => {
    if (!inputRef.current) {
      return;
    }

    console.log(inputRef.current.getBoundingClientRect());
  }, []);

  return <input {...props} ref={combinedInputRef} />;
});

export function UsageWithCombine() {
  const inputRef = useRef<HTMLInputElement | null>(null);

  const focus = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <Input ref={inputRef} />
      <button onClick={focus}>Focus</button>
    </div>
  );
}

説明

開発者が標準のリファレンス、コールバックリファレンス、オプションでnullまたはundefinedを渡すことができるuseCombinedRefフックを実装しました。フック自体は、リファレンス配列をループするuseCallbackにすぎません。引数がnullの場合は無視しますが、リファレンスが関数の場合は要素とともにそれを呼び出し、標準リファレンスオブジェクトの場合はref.currentを要素に設定します。

このようにして、複数のrefsを1つにマージします。前述の例では、Inputコンポーネント内のgetBoundingClientRectと外部のフォーカス呼び出しが正常に機能します。

React 19におけるコールバックrefsの変更点は何ですか?

自動クリーンアップ

Reactは今、要素がアンマウントされるときにコールバックrefsのクリーンアップを自動で処理し、リソース管理がより簡単になりました。Reactチームがドキュメントで示している例を以下に示します:

TypeScript

 

 {
    // ref created

    // NEW: return a cleanup function to reset
    // the ref when element is removed from DOM.
    return () => {
      // ref cleanup
    };
  }}
/>

詳細については、公式ブログ投稿で詳細を読むことができます。そこでは、すぐにnullを使ってrefsをクリーンアップすることが廃止され、refsをクリーンアップする単一の標準化された方法が残される可能性があると述べられています。

通常のrefsとコールバックrefsの選択

  • シンプルなDOM要素へのアクセスが必要な場合や、レンダリング間に値を保持するだけで十分で、アタッチまたはデタッチ時に追加の処理が必要ない場合は、標準refs(useRef)を使用してください。
  • 要素のライフサイクルに対してより詳細な制御が必要な場合や、ユニバーサルコード(たとえば、独自のライブラリやパッケージ内)を作成している場合、複数のrefsをまとめて管理する必要がある場合は、コールバックrefsを使用してください。

結論

Reactのコールバック参照は、開発者がDOM要素を扱う際に追加の柔軟性と制御を提供する強力なツールです。ほとんどの場合、useRefを使用した標準のオブジェクト参照が日常的なタスクには十分ですが、上記で議論したようなより複雑なシナリオに役立つことがあります。

Source:
https://dzone.com/articles/react-callback-refs-guide