Gemcook Tech Blog
🐇

useRefを使った値管理ガイド

2021/12/03に公開

概要

useRef は DOM にアクセスするために使用できますが、コンポーネント内に値を保持するためにも使えます。
この記事では useState と useRef の違いを見ながら、どのようなユースケースで useRef が有効かをサンプルを見ながら解説します。

しかしながら useRef() は ref 属性で使うだけではなく、より便利に使えます。これはクラスでインスタンス変数を使うのと同様にして、あらゆる書き換え可能な値を保持しておくのに便利です。

https://ja.reactjs.org/docs/hooks-reference.html#useref

はじめに

DOM やコンポーネントインスタンスの参照を得る以外に useRef を使用する機会はそこまで多くありません。大抵の場合 useState やライブラリによる状態管理で事足りるからです。
ですが特定のケースにおいて ref を使用するとパフォーマンスに優れ、シンプルな実装にできることがあります。useRef が有効なケースを知るためにまずは useState との違いを確認します。

useState と useRef の違い

useState と比較したとき useRef の重要な特徴は 3 つです。

  1. 更新による再レンダリングが発生しない
  2. 値が同期的に更新される
  3. 返却される Ref オブジェクトは同一である

1. 更新による再レンダリングが発生しない

useState は setState を実行した後コンポーネントが再レンダリングされますが useRef は更新しても再レンダリングされることはありません。
DOM の更新が発生しない分パフォーマンスには良いです。当然ですが UI 上で使用すると 「更新したのに画面に反映されない!」 なんてことになるので 描画に使用しない値他の処理で使用する一時データ の管理に向いています。

2. 値が同期的に更新される

「値が同期的に更新される...」なにを当然のことを言ってるんだと思うかもしれません。
しかし useState はそうではありません。
例えば、一度は以下のようなコードを書いてみたことがあるのではないでしょうか?

Counter.tsx
const Counter = () => {
  const [count, setCount] = useState(0);

  return (
    <button
      onClick={() => {
        console.log(count);
        setCount(1);
        console.log(count);
      }}
    >
      カウント1
    </button>
  );
};

このボタンをクリックした結果としては setCount を呼び出したにも関わらず count は以前の値を保っています。

Result

これは useState が同一の render 内で同じ値を保持し続けるためです。
React は主にパフォーマンスのために同一のイベント処理が全て終了した後にコンポーネントを一度に再レンダリングします。
そして新しくレンダリングされたコンポーネントで初めて更新された state を参照することができるようになります。

このレンダリング間における状態の詳しい仕組みは以下の記事が参考になります。

https://overreacted.io/ja/a-complete-guide-to-useeffect/

それでは先程のコードを useRef に書き換えてみましょう。

Counter.tsx
const Counter = () => {
  const count = useRef(0);

  return (
    <button
      onClick={() => {
        console.log(count.current);
        count.current = 1;
        console.log(count.current);
      }}
    >
      カウント1
    </button>
  );
};

Result2

値が同期的に更新されていることが確認できます。
useState による状態管理では値を更新しても次のレンダリングまで最新の状態を参照することができません。しかし useRef ならば常に最新の値を参照することができます。

3. 返却される Ref オブジェクトは同一である

useState は初期値に渡した値または setState で更新した状態がそのまま返却されますが useRef は Ref オブジェクトが返却されます。
useRef で返却される Ref オブジェクトは呼び出したコンポーネントが存在する限り同一のオブジェクトです。
これは useCallback, useMemo, useEffect など依存関係を持つ Hooks を使用する際に便利です。
上記のような依存関係を持つ Hooks は === で前回の値と新しい値が同一かどうか判定します。

そのため useRef で返却される Ref オブジェクトは依存関係に含めても依存関係に影響を起こさず、eslintreact-hooks/exhaustive-deps ルールを有効にしていても Ref は依存関係に含めるように勧められることもありません。

useRef 使用例

useState と useRef の違いを踏まえた上で、useRef の使用例を見ていきます。

基本的な使用方法

「更新による再レンダリングが発生しない」「値が同期的に更新される」 点から画面に描画しない値であれば非常にシンプルに取り回せます。

コンポーネントのマウント状態、特定の処理が何度呼び出されたか、送信前のログデータを保持しておくなど様々な使用用途が考えられます。

例えばコンポーネント呼び出し時にどうしても一度だけ呼び出したい処理があったとします。
以下の記事でも触れられていますが、React v18 からは StrictMode 下の開発環境で依存配列が [] だったとしても useEffect が複数回呼び出される可能性があります。
(現在でも Fast Refresh 等で再現します)

React 18 alpha 版発表まとめ

これは ref でフラグを管理すれば共通フックとして簡単に実装できます。

useEffectOnce.ts

const useEffectOnce = (effect: React.EffectCallback) => {
  const called = useRef(false);

  useEffect(() => {
    if (!called.current) {
      called.current = true;
      return effect();
    }
  }, []);
};

usePrevious

usePrevious は公式でも紹介されている有名なカスタムフックで、前回のレンダリング時の値を保存して使用できます。
また、この前回の値は変数として保持されるため UI 上に描画しても意図通りに動作します。

usePrevious.ts
const usePrevious = <T extends unknown>(value: T) => {
  const ref = useRef<T>();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}
const Counter = () => {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    ...
  );
};

この usePrevious の実装を見て「Ref の値は値が同期的に更新されるのでは?これで前回の値を返せるの?」と疑問を持つ方もいるかも知れません。
これは return で ref.current を返却しており、レンダリング後の useEffect が実行される前の値を返却しているためです。
以下のように書き換えるとわかりやすいでしょうか。

usePrevious.ts
const usePrevious = <T extends unknown>(value: T) => {
  const ref = useRef<T>();
  // もともとの実装で return していた以前の値
  const prev = ref.current;
  useEffect(() => {
    ref.current = value;
  });

  return { ref, prev };
}
const Counter = () => {
  const [count, setCount] = useState(0);
  const { ref, prev } = usePrevious(count);

  return (
    <button
      onClick={() => {
        console.log(ref, prev);
        setCount((prevCount) => prevCount + 1);
      }}
    >
      カウントアップ
    </button>
};

実行結果は以下のとおりです。
useEffect で更新される前に変数に代入した prev では前回の値が参照でき、ref には最新の値が入っていることが確認できます。

ref に関数を保存する

カスタムフックを使用していると度々 callback を受け取るフックを作りたくなることがあります。
例えば useApi のような外部にリクエストを行う関数を受け取り、レスポンスデータを返却するようなカスタムフックを作るとします。

useApi.ts
type PromiseFunc = (...cb: any) => Promise<any>;
type PromiseReturnType<T extends PromiseFunc> = ReturnType<T> extends Promise<infer T> ? T : never;

const useApi = <T extends PromiseFunc>(callback: T) => {
  const [data, setData] = useState<PromiseReturnType<T>>();

  const request = useCallback(async () => {
    const data = await callback();
    setData(data);
  }, [callback]);

  useEffect(() => {
    request();
  }, [request]);

  return data;
}

引数に callback を受け取り、レスポンスデータを useState で保存するだけのカスタムフックです。
ですが、このコードは無限ループを引き起こす危険性を含んでいます。

Page.tsx
// ダミーのAPIリクエスト
const apiRequest = (): Promise<number> => {
  return new Promise((resolve) =>
    setTimeout(() => resolve(Math.floor(Math.random() * 1000)), 3000)
  );
};

const Counter = () => {
  const data = useApi(() => apiRequest());
  console.log(data);

  return ...;
};

例えば useApi フックの callback にメモ化されていない関数が渡されると非常に危険です。
リクエスト終了後に setData でレンダリングされてメモ化されていない callback が再生成されることにより、再度 request 関数が実行されて無限に API リクエストしてしまうことが想定されます。

この無限ループを安直に解決しようとすると useEffect の第 2 引数を空にしてしまう方法が考えられます。react-hooks/exhaustive-deps ルールに指摘はされますが eslint-disable で無効にしてしまえば実装としては問題ないでしょう。
しかし今後オプションを受け取るような機能追加がされたときに何に依存しているかを考え直す必要があるため、依存配列に手を入れるのはできれば最終手段にしておきたいところです。

useApi.ts
  useEffect(() => {
    request();
  }, []);

こういったときに便利なのが useRef に関数を保存する方法です。

useApi.ts
const useApi = <T extends PromiseFunc>(callback: T) => {
  const [data, setData] = useState<PromiseReturnType<T>>();
  const ref = useRef<T>();

  useEffect(() => {
    ref.current = callback;
  }, [callback]);

  const request = useCallback(async () => {
    const data = await ref.current?.();
    setData(data);
  }, []);

  useEffect(() => {
    request();
  }, [request]);

  return data;
}

callback が更新される毎に ref に保持している関数を更新して、ref.current で関数を呼び出します。
ref オブジェクトは同一である事が保証されているため、関数を ref に入れておくことで依存配列を気にせず実装することができました。

最後に

useRef は useState のように状態管理の主役というわけではないですが、非常に小回りの効く便利な Hooks です。
useState との違いを意識した上で適切に使用すれば実装の幅が広がります。

参考

GitHubで編集を提案
Gemcook Tech Blog
Gemcook Tech Blog

Discussion