📑

【React】useRefの深掘り ~ useStateとの違い、レンダリングの際の挙動等

2024/07/21に公開

はじめに

本記事ではuseRef, useStateについて説明した後、両者の違いについて解説します。

useRef とは

レンダリングに必要ない値を参照する際に用いられる React Hook です。

入力

  • initialValue: refcurrentの初期値。最初のレンダリング後、この値は無視されます。

返り値

  • currentというプロパティを有するオブジェクトを返します。currentは最初initialValueで初期化されますが、後に他の値を指定することもできます。このrefオブジェクトを JSX ノードのref属性として React に渡すと、React はそのcurrentプロパティに設定します。

ポイント

  • ref.currentを変更しても React は再レンダリングを行いません。refは単なる JavaScript オブジェクトであり変更が行われても React はそれを認識しないからです。
  • ストリクトモードではコンポーネントは 2 度呼び出されます。これは開発時のみの動作で本番環境には影響しません。各refオブジェクトは 2 回生成されますが、そのうちの 1 つのバージョンは破棄されます。

使い方

refによる値の参照

前述の通り、useRefは初期値として与えられた値に設定されたcurrentプロパティを持つrefオブジェクトを返します。次回以降のレンダリングでcurrentプロパティを変更して情報を保存し後に読み取ることができます。状態(state)との違いはrefを変更しても再レンダリングが発生しない点です。すなわち、refはコンポーネントの視覚的な出力に影響を与えない情報を保存するのに適しています。

すなわち、refを用いることにより、以下のことを保証できます。

  • 通常の変数はレンダリング毎にリセットされますが、useRefでは再レンダリング間で情報を保存することができる
  • 再レンダリングをトリガーする state 変数とは異なり再レンダリングをトリガーしない
  • 外部の変数とは異なり、情報はコンポーネントのコピー毎にローカルなもので共有されない

useStateとの違いを以下の例から見てみます。startTime, nowはレンダリングに用いられるため、useStateを用いています。同時にボタンを押してインターバルを停止するためにインターバル ID を保持する必要もあります。インターバル ID はレンダリングには使用されないためrefに保存し手動で更新するのが適切です。

import { useState, useRef } from 'react';

export default const StopWatch = () => {
    const [startTime, setStartTime] = useState(null);
    const [now, setNow] = useState(null);
    const intervalRef = useRef(null);

    const handleStart = () => {
        setStartTime(Date.now());
        setNow(Date.now());

        clearInterval(intervalRef.current);
        intervalRef.current = setInterval(() => {
            setNow(Date.now());
        }, 10);
    }

    function handleStop = () => {
        clearInterval(intervalRef.current);
    }

    let secondsPassed = 0;
    if (startTime != null && now != null) {
        secondsPassed = (now - startTime) / 1000;
    }

    return (
        <>
            <h1>Time passed: {secondsPassed.toFixed(3)}</h1>
            <button onClick={handleStart}>
                Start
            </button>
            <button onClick={handleStop}>
                Stop
            </button>
        </>
    );
}

refによる DOM 操作

React は自動で DOM をレンダリングの結果に合致するように更新するので通常 DOM を操作する必要はありません。しかしノードにフォーカスしたいとき、ノードまでスクロールしたいとき、大きさや位置を計測したいときなど DOM にアクセスする必要のある場面があります。

const Component = () => {
    const inputRef = useRef(null);

    const handleClick = () => inputRef.current.focus();

    return (
        <>
            <input ref={inputRef} />
            <button onClick={handleClick}>
                Focus the input
            </button>
        </>
    );
}

上の例では React が DOM ノードを作成し画面に表示した後、React はrefcurrentプロパティに DOM ノードがセットされます。これにより<input>の DOM ノードにアクセスし以下のような操作を行うことができます。

const handleClick = () => inputRef.current.focus();

currentはノードが画面から除去された時にnullに設定されます。

refの再生成の防止

React はrefの初期値を次回レンダリング時に無視します。

const Video = () => {
    const playerRef = useRef(new VideoPlayer());
}

new VideoPlayer()の結果は初回レンダリングのとき以外使われていないにもかかわらず、全てのレンダリングでこの関数を実行しています。もしこれが計算のかかるオブジェクトならこの操作は無駄な操作です。これを解決するために、以下のように初期化する方法があります。

const Video = () => {
    const playerRef = useRef(null);
    if (playerRef.current === null) {
        playerRef.current = new VideoPlayer();
    }
}

通常ref.currentの値をレンダリング中に読み書きすることは許されていませんが、この場合は結果が常に同じで初期化の時のみifの内部が実行されるので予測可能なので問題ありません。

他のコンポーネントの DOM ノードにアクセスする時

<input />のような built-in のコンポーネントにrefを与えると React はcurrentプロパティを対応する DOM ノードに与えますが、自身でコンポーネントを記述するとnullが返ってきます。デフォルトで React はコンポーネントに他のコンポーネントの DOM にアクセスすることを子コンポーネントに対しても許さないので、自身でコンポーネントを記述するときは明示的にrefを子コンポーネントに渡す挙動を記述する必要があります。例えば以下のようにforwardRefを用いて記述します。

const MyInput = forwardRef((props, ref) => {
    return <input {...props} ref={ref}>
})

export default const Form() => {
    const inputRef = useRef(null);

    const handleClick = () => inputRef.current.focus();

    return <MyInput ref={inputRef} />;
}

useImperativeHandleについて

上の例ではMyInputは DOM 要素をアクセス可能にしています。これにより親コンポーネントがfocusを呼ぶことができるようになりますが、これにより親コンポーネントに想定外の挙動を許してしまう可能性があります。useImperativeHandleを用いることで、refを開放することにより許す機能性を制限することができます。

useImperativeHandle(ref, createHandle, dependencies?)の入力等は以下のとおりです。

入力
  • ref: forwardRefから受け取ったref
  • createHandle: 入力を受け付けず、アクセス可能にしたいref操作を返す関数。
  • (任意)dependencies: createHandleの中で参照された全ての reactive な値のリスト。Object.is比較によりそれぞれの依存要素が比較されます。もし再レンダリングがdepenenciesの要素の変更に繋がったとき、あるいはこのdependenciesを省略したとき、createHandle関数は再実行され、新しく作られたハンドルがrefに割り当てられます。
返り値

useImperativeHandlenullを返します。

使用例

focusscrollIntoViewの二つのメソッドのみアクセス可能にしたいとき、例えば以下のようなコードを書きます。

const MyInput = forwardRef(function MyInput(props, ref) {
    const inputRef = useRef(null);

    useImperativeHandle(ref, () => {
        return {
            focus() {
                inputRef.current.focus();
            },
            scrollIntoView() {
                inputRef.current.scrollIntoView();
            }
        }
    }, []);

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

いつ React がrefをアタッチするか

React では

  • レンダリングにおいて、React はコンポーネントを呼び出し何がスクリーンにあるべきかを特定します。
  • コミットにおいて、React は差分を DOM に反映します。

前述したように、レンダリング中refにアクセスするべきではありません。最初のレンダリング中、DOM ノードはまだ生成されておらずref.currentnullです。また更新のためのレンダリング中も DOM はまだ更新されておらず、読み出すにはまだ早い状態です。React はref.currentの値をコミットの段階でセットします

useEffectと共に用いる時

通常、refはイベントハンドラからアクセスされます。ただし、イベントが存在しない状態でrefに何か操作を行いたいときはEffectを用いると解決する可能性があります。例えば以下のコードを見てみます。

function VideoPlayer({ src, isPlaying }) {
    const ref = useRef(null);

    if (isPlaying) {
        ref.current.play();
    } else {
        ref.current.pause();
    }
    return <video ref={ref} src={src} loop playsInline />;
}

このコードがうまく動かない理由は

  • レンダリング中に DOM に操作をしようとしている。React ではレンダリングは純粋な JSX の計算であるべきで DOM の操作のような副作用を有してはならない。
  • VideoPlayerが最初に呼ばれる際、DOM は存在していない。
    ことが原因です。この解決方法として、useEffectでラップすることでレンダリング計算の外側に動かすことが考えられます。
function VideoPlayer({ src, isPlaying }) {
    const ref = useRef(null);

    useEffect(() => {
        if (isPlaying) {
            ref.current.play();
        } else {
            ref.current.pause();
        }
    }, [isPlaying]);
    return <video ref={ref} src={src} loop playsInline />;
}

DOM の更新をEffectでラップすることにより、React にスクリーンの更新を先にさせ、その後 Effect が走るようにすることができます。

useState, useRefの違い

一部上述しましたが、staterefには以下のような違いがあります。

ref state
useRef(initialValue){ current: initialValue}を返す useState(initialValue)は現在の状態変数及び setter 関数を返す
値を変更しても再レンダリングをトリガーしない 値を変更した際に再レンダリングをトリガーする
レンダリングプロセスの外でcurrentの値を変更できる stateの setter 関数を用いて状態変数を変更し再レンダリングされるようにする
レンダリング中にcurrentを読み書きするべきでない いつでもstateを読めるが、各々のレンダリングは状態のスナップショットを有する

簡潔にまとめるとuseRefは以下のように実装されているとイメージすることができます。

function useRef(initialValue) {
    const [ref, unused] = useState({ current: initialValue });
    return ref;
}

useRefは常に同じオブジェクトを返す必要があるため、useStateの setter 関数は不要です。

さらにrefstateの違いとして、stateは全てのレンダリングに対してスナップショットのように(後述)働きますが、refは値を変更するとただちに更新される点があります。

ref.current = 5;
console.log(ref.currente); // 5

この理由はref 自体は通常の JavaScript オブジェクトであるからです。

「スナップショットのように働く」とは

stateの setter 関数により新たな値をセットしたとき、値が書き変わったように見えますが、実際には再レンダリングがトリガーされています。以下のコードを考えます。

const Form = () => {
    const [isClicked, setIsClicked] = useState(false);
    if (isClicked) {
        return <h1>The button is clicked</h1>
    }
    return (
        <button onClick={() => setIsClicked(true)}>
    )
}

ボタンを押した際、以下が実行されます。

  1. onClickが実行される
  2. setIsClicked(true)によりisClickedが true になる
  3. React はisSentの値に基づきコンポーネントを再レンダリングする

レンダリングとは React が関数であるコンポーネントを呼び出すことを意味しています。props, イベントハンドラ、ローカル変数は全てそのレンダリングの瞬間の状態から計算されています。React はこのスナップショットに基づき画面を更新しイベントハンドラを接続します。結果、ボタンを押した際 JSX 内のクリックハンドラがトリガーされます。

まとめるとレンダリングの際、以下が実行されます。

  1. React が関数を再び呼び出す
  2. 新しい JSX スナップショットを関数が返す
  3. React がスナップショットに合うように画面を更新する

状態変更が再レンダリングをトリガーするという現象を理解するために以下の例を見てみましょう。

const Counter = () => {
    const [number, setNumber] = useState(0);

    return (
        <>
            <h1>{number}</h1>
            <button onClick = {() => {
                setNumber(number + 1);
                setNumber(number + 1);
                setNumber(number + 1);
            }}>+3</button>
        </>
    )
}

直感的にボタンを押すたびにsetNumberが 3 回呼び出されるので+3 されそうですが、実際には+1 しかされません。理由は状態を変えても次のレンダリングでしか反映されないからです。全てのsetNumber(number + 1)においてnumber0なので次のレンダリングでは1にしかなりません。

また、ここからReact はイベントハンドラの全てのコードを実行するまでつぎの状態更新を行わないことがわかります。例えばイベントハンドラ内でonClick = () => { setColor('blue'); setColor('orange'); setColor('pink'); )}のように指示しても実際に実行されるのはsetColor('pink')のみです。

注意点

レンダリング中にref.currentを読み書きしてはならない

React はコンポーネント本体が純粋な関数として動くことを想定しています。すなわち

  • 入力(props, state, context)が同一なら同一の JSX を返す
  • あるコンポーネントが違う順序や異なる引数で呼ばれても他の呼び出しには影響しない

レンダリング中になんらかの情報が必要な時、stateを代わりに用いるべきです。なぜなら React はいつref.currentが変更されるのか知らないからです。レンダリング中にrefの値を読み書きすることはコンポーネントの挙動を予測しづらくします(例外的に、if (!ref.current) ref.current = new Thing()のようなコードは初回のレンダリングに一度のみrefの値をセットするので許されます)。

useEffect の依存配列にref.currentを指定する時

以下のようなコードを考えます。

export default function App() {
  const ref = useRef();
  useEffect(() => {
    if (ref.current) {
      console.log("hello!");
    }
  }, [ref.current]);
  return (
    <>
      <h1 ref={ref}>{"hello!"}</h1>
    </>
  );
}

このコードを実行すると、hello!とコンソールに 2 回出力されます。これは直感的ではなく想定されていない挙動です。

React の公式ドキュメント(英語版)には以下のような内容が記述されています。

refstable identity(React はレンダリング毎にuseRefが同じオブジェクトを返すことを保証している)ことに起因します。useRefの結果は変わらないのでEffectrefの変化によってトリガーされることはありません。よって含めても含めなくても問題ありません。同様の性質はuseStateの setter 関数にもみられます。ただし、refが親コンポーネントから渡されたときなどはuseEffectの依存配列に入れる必要がある場合があります。これは親コンポーネントが同じrefを渡しているのか違うrefを条件毎に渡しているのかわからない場合があるからです。

ただこのような問題を見る限り、ref.currentを依存配列に加えないのが正しいというのが個人的な感想です。理由はrefはレンダリングフローの外で変更されるべき値であり、再レンダリングをトリガーしたかったらrefをそもそも用いるべきではないからです。

代わりに以下のように記述することができます。

export default function App() {
  const ref = useCallback((node: any) => {
    if (node) {
      console.log("hello");
    }
  }, []);
  return (
    <>
      <h1 ref={ref}>{"hello!"}</h1>
    </>
  );
}

このコードを実行するとhello!と一回のみコンソールに出力されます。もしrefが変化した時Effectを走らせたい時、上記の例のようにcallback refを用いると良いかもしれません。

参考資料

Discussion