LoginSignup
107
71
お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

そのuseRef+useEffect、refコールバックのほうが良いかも?

Last updated at Posted at 2024-06-19

Reactにおいて、useEffectのユースケースとして知られているのが、DOMノードに直接アクセスしなければいけない場合です。useRefでDOMノードをrefオブジェクトに取得し、エフェクト内からDOMノードにアクセスするというのがその場合の基本的なやり方です。

このようなuseRef + useEffect の使い方は、問題ない場合もありますが、実は別の手段を使った方がいい場合もあります。その場合に別の手段として適しているのがrefコールバックという機能です。

そこで、この記事ではどのような場合にuseRef + useEffectよりもrefコールバックが適しているのか、そしてrefコールバックを使う場合の注意点について解説します。

復習: refコールバックとは

React DOMでは、組み込み要素(divなどHTMLの要素)に対してrefという特殊なpropを与えることができます。refとして関数を与えるのがrefコールバックです。引数は、TypeScriptの型で書くと、例えばdivの場合はHTMLDivElement | nullです。

1つの関数は少なくとも2回呼ばれます。当該DOM要素がマウントされたときに、そのDOM要素が引数に与えられます。また、そのDOM要素がアンマウントされたとき(ページから取り除かれたとき)は、nullを引数に与えて関数を呼び出すことでそのことが知らされます。

例を見てみましょう。

refコールバックの具体例
export default function App() {
  const [show, setShow] = useState(false);

  return (
    <div>
      <label>
        <input
          type="checkbox"
          checked={show}
          onChange={(e) => {
            setShow(e.currentTarget.checked);
          }}
        />
        Show
      </label>
      {show && (
        <div
          ref={(node) => {
            console.log("ref callback", node);
          }}
        >
          div!
        </div>
      )}
    </div>
  );
}

この例では、チェックボックスを操作することでdivを出したり消したりできます。この場合、divが出たときにrefコールバックが引数にHTMLDivElementを伴って呼び出され、divが消えたときには引数nullで呼び出されます。

useRef + useEffectではうまくいかない例

では、ここから本題に入っていきます。refを使ったDOM操作を行う場合、useRef + useEffectが定番です。しかしそれではうまくいかないというのが今回の話題でした。そこで、うまくいかない例をお見せします。

まずは、「divの外をクリックするとカウントが増える」という例で考えてみましょう。「外をクリックすると」系のロジックはモーダルダイアログなどを手実装するときによく使われるロジックですね。最近はdialogやpopoverといったHTML標準の道具が揃ってきているのでこのようなロジックを実装する機会は減っていそうです。

サクッと実装するとこんな感じになります。

export default function App() {
  const [count, setCount] = useState(0);

  const divRef = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    const div = divRef.current;
    if (div === null) return;

    const controller = new AbortController();
    document.addEventListener(
      "click",
      (event) => {
        // divの中をクリックしたときは反応しない
        if (div.contains(event.target as Node)) return;
        setCount((count) => count + 1);
      },
      {
        signal: controller.signal,
      }
    );
    return () => {
      controller.abort();
    };
  }, []);

  return (
    <div>
      <p>count = {count}</p>
      <div className="square" ref={divRef} />
    </div>
  );
}

useEffectの中に注目してください。divの外ならどこでもクリックに反応するというロジックは、documentにclickイベントを設定し、イベント発火時にクリックされたのがdivの中であれば処理を中止するという方法で表現されています。この判定はdiv.contains(event.target)です。この判定のためにdivのDOMノードが必要なので、それをdivRefで得ています。

「count=8」という文字列と黄色いdiv要素が表示されたスクリーンショット

この実装は意図通りに動きます。では、先ほどの「チェックボックスをオンにするとdiv要素が表示される」という機能を組み込んでみましょう。

export default function App() {
  const [show, setShow] = useState(false);
  const [count, setCount] = useState(0);

  const divRef = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    const div = divRef.current;
    if (div === null) return;

    const controller = new AbortController();
    document.addEventListener(
      "click",
      (event) => {
        // divの中をクリックしたときは反応しない
        if (div.contains(event.target as Node)) return;
        setCount((count) => count + 1);
      },
      {
        signal: controller.signal,
      }
    );
    return () => {
      controller.abort();
    };
  }, []);

  return (
    <div>
      <label>
        <input
          type="checkbox"
          checked={show}
          onChange={(e) => {
            setShow(e.currentTarget.checked);
          }}
        />
        Show
      </label>
      <p>count = {count}</p>
      {show && (
        <div className="square" ref={divRef}>
          div!
        </div>
      )}
    </div>
  );
}

こうすると、もう意図通りには動かなくなります。チェックボックスをクリックしてdivを表示して、divの外をクリックしてもカウントは増えません。

上のコードから、うまく動かない理由を読み取ることはできるでしょうか?

そうです。このコードではuseEffectの依存配列が[]であり、Appがマウントされたときにしかエフェクトが発火しません。その時点ではdivは表示されていないのでdivRef.currentがnullであり、そもそもdocumentにイベントが登録されていないのです。実務でReactをお使いの方は、このような実装をしてうまく動かなくて困った経験があるのではないでしょうか。

そこまで望ましくない解決策

一応、上の問題についてはuseEffectを使ったまま解決する方法もいくつかありますが、完璧とは言えません。

① 依存配列にshowを足す

-   }, []);
+   }, [show]);

このようにuseEffectの依存配列にshowを足せば、showがtrueになってdivが表示されたタイミングでエフェクトを再発火できるため意図通りの動きになります。しかし、これはuseEffectの中で使われていない値を依存配列に含んでいることになりますからuseEffectの正しい使い方ではありません。ESlintでreact-hooks/exhaustive-depsルールを有効にしている場合は警告の対象となります。

② 依存配列を消す

-   }, []);
+   });

依存配列を消してしまえば、再レンダリングのために発火するエフェクトとなり、divが出現したタイミングでエフェクトを発火することがでいます。

これは正しい解決策ではありますが、エフェクトが必要以上に頻繁に発火してしまうので気持ち悪いという問題があります。

また、この方法では対処できないケースも存在します。実際にはdivをレンダリングしているのが子コンポーネントであり、子コンポーネントは親コンポーネントよりも頻繁に再レンダリングしているというようなケースです。そのため、これも万能な解決策ではありません。

③イベントハンドラの中でdivRef.currentを読む

useEffectの実装をこのように変えることで、documentに対してclickイベントは常に登録しておいて、イベントハンドラの中でdivRef.currentを見る実装にするという手もあります。

  useEffect(() => {
    const controller = new AbortController();
    document.addEventListener(
      "click",
      (event) => {
        const div = divRef.current;
        if (div === null) return;

        // divの中をクリックしたときは反応しない
        if (div.contains(event.target as Node)) return;
        setCount((count) => count + 1);
      },
      {
        signal: controller.signal,
      }
    );
    return () => {
      controller.abort();
    };
  }, []);

これも動きますし、①②よりはマシな選択肢です。それでも、divが出ていないときにもdocumentにイベントハンドラを追加する必要があるのがあまり嬉しくありません。clickならまだしも、pointermoveとかscrollとかだったりしたら猶更です。

refコールバックを使った解決策

ということで、refコールバックを使うとどのように解決できるのか見てみましょう。

export default function App() {
  const [show, setShow] = useState(false);
  const [count, setCount] = useState(0);

  const divRef = useMemo(() => {
    let cleanup: (() => void) | undefined;
    return (div: HTMLDivElement | null) => {
      if (div === null) {
        cleanup?.();
        return;
      }
      const controller = new AbortController();

      document.addEventListener(
        "click",
        (event) => {
          // divの中をクリックしたときは反応しない
          if (div.contains(event.target as Node)) return;
          setCount((count) => count + 1);
        },
        {
          signal: controller.signal,
        }
      );
      cleanup = () => {
        controller.abort();
      };
    };
  }, []);

  return (
    <div>
      <label>
        <input
          type="checkbox"
          checked={show}
          onChange={(e) => {
            setShow(e.currentTarget.checked);
          }}
        />
        Show
      </label>
      <p>count = {count}</p>
      {show && (
        <div className="square" ref={divRef}>
          div!
        </div>
      )}
    </div>
  );
}

このように、divRefが関数となりました。こうすることで、divがレンダリングされていないときはdocumentにイベントハンドラを登録せず、余計な処理もすることなく意図通りの処理を実現できました。

一般に、useEffect内でコンポーネント内でレンダリングされるDOMノードにアクセスしたくなった場合、このような罠を踏む可能性があるためrefコールバックを優先的に検討したいです。

React 19だと……

上のサンプルコードはcleanup周りがちょっとごちゃついている印象がありますね。この点は、React 19の新機能によって改善する予定ですのでご安心ください。React 19の新機能(refコールバックがクリーンアップ関数を返せる機能)を使えば次のような実装になります。

  const divRef = useCallback((div: HTMLDivElement) => {
    const controller = new AbortController();

    document.addEventListener(
      "click",
      (event) => {
        // divの中をクリックしたときは反応しない
        if (div.contains(event.target as Node)) return;
        setCount((count) => count + 1);
      },
      {
        signal: controller.signal,
      }
    );
    return () => {
      controller.abort();
    };
  }, []);

すっきりしてuseEffectに似た感じになりましたね。

refコールバックをメモ化する

ここからはrefコールバックの注意点について解説します。上の例ではrefコールバックを丁寧にuseMemoでメモ化していました。これには意味があります。

メモ化しないと、毎回新しい関数オブジェクトが作られてrefとして渡されることになります。

そうなると、Reactは「新しいrefコールバック関数が与えられたので、古いコールバック関数から新しいほうへ制御を移さなければならない」と判断します。つまり、古いほうにはnullを渡してクリーンアップし、新しいほうにDOM要素を渡します。このように、refコールバックのクリーンアップは、DOM要素がページから取り除かれたときだけでなく、refコールバック側がさし代わったときにも発生するのです。

これが頻繁に起こると、結局レンダリングのたびにイベントハンドラの登録と解除が繰り返されることになるので良くありません。そのため、最小限の処理で済むようにメモ化しましょう。

React Compilerとの関係

「React Compilerを使えば手でメモ化する必要がなくなる」という話を聞いたことがある方もいるでしょう。その話はrefコールバックにも当てはまるのでしょうか。

答えはYESです。React Compiler Playgroundで試した結果、refコールバック関数もしっかりとメモ化の対象になりました。つまり、React Compilerを使う場合はrefコールバックをuseMemoやuseCallbackでメモ化する必要はないということです。

一方で、これは注意も必要です。敢えてrefコールバックをメモ化しないことで「レンダリングのたびに呼び出される」という挙動のコールバックを作ることができますが、これはrefコールバックの正しい使い方ではないということを示唆しています。正しい使い方は、あくまでDOMのマウント・アンマウントやDOMノードの変化に反応するという使い方です。

refコールバックの実行順序

refコールバックを扱う際にすこし気を付けなければいけないのは、他のrefとの兼ね合いです。複数のrefコールバックが、あるいはrefコールバックとrefオブジェクトが協調して動くような場合は実行順序の問題が発生します。

useRef + useEffectの場合は、すべての ref.current がセットされてからエフェクトが発火されるので、ref.currentがセットされる順序を気にする必要はありませんでした。

しかしrefコールバックは関数ですから、呼び出されたらその瞬間に処理が走ります。呼び出すべきrefコールバックが複数ある場合には、どうしてもどちらかが先に呼ばれてどちらかが後に呼ばれます。

注意しておきたいのは、できる限りrefコールバックの実行順序に依存すべきではないということです。今のところReactのドキュメントにrefコールバックの実行順序は明記されていません。つまり、Reactの都合に応じて変わるかもしれないということです(肌感としては、変わるときは破壊的変更としてアナウンスされるだろうけど、変えるべきであれば普通に変えてきそうに思います)。

ですから、refコールバックを複数協調させたい場合には「全部呼ばれるまで待つ」といった実装が必要になります。筆者は最近React Ariaを取り扱ったのですが、refコールバックがけっこう活用されているようなので、こういった制御もされていそうです。

refオブジェクトとの競争

特に、refコールバックとrefオブジェクトが入り混じる場合には注意を要します。Reactがrefコールバックを呼び出すタイミングは、ref.currentがセットされるタイミングと同じです。つまり、各refに対して、ざっくり言えば次のような処理が行われているということです。

if (typeof ref === "function") {
  ref(node);
} else {
  ref.current = node;
}

つまり、refコールバックが呼び出された時点ではまだ、他のDOM要素のref.currentは古いままということも考えられます。現在のReactの挙動では、このようにrefコールバックもrefオブジェクトも入り混じって処理されます。

<div ref={() => { /* ... */ }} />   // ← ①これが呼ばれる
<p ref={refObject} />               // ← ②refObject.currentにDOMノードが入る
<div ref={() => { /* ... */ }} />   // ← ③これが呼ばれる

このことは、refコールバックの協調をより一層ややこしくします。筆者は最近けっこう複雑なrefコールバックを扱いましたが、ちゃんとやるのは非常にたいへんで、読解難易度も高いコードになってしまうので、他の手段も検討すべきだと感じました。

useEffectとrefコールバックの対比

useEffectとrefコールバックは対比される関係にあります。特にReact 19でrefコールバックのクリーンアップ関数が追加されることを踏まえると、両者の使い方はとても似たものになります。

  // useEffect
  useEffect(() => {
    const div = divRef.current;
    if (div === null) return;

    const controller = new AbortController();
    document.addEventListener(/* ... */);
    return () => {
      controller.abort();
    };
  }, []);

  // refコールバック
  const divRef = useCallback((div: HTMLDivElement) => {
    const controller = new AbortController();
    document.addEventListener(/* ... */);
    return () => {
      controller.abort();
    };
  }, []);

エフェクトもrefコールバックも、発火→クリーンアップというライフサイクルを持つ点が共通しています。

対比される点は、そのサイクルが何に乗っているのかという点です。

useEffectは、レンダリングのサイクルの上に乗っています。つまり、コンポーネントがある状態のスナップショットをUIに反映させているときに、ReactによるDOM操作だけではできないUIへの反映作業をuseEffectで行うのです。つまり、エフェクトのライフタイムは、状態のスナップショットのライフタイムと一致しているのです。だから、コンポーネントの状態が変わったときはエフェクトがクリーンアップされるのです(依存配列によって最適化される場合は継続することもあります)。

この考え方については以下の記事でより詳細に説明されているので、参考にしてください。

一方で、refコールバックはDOMノードのライフサイクルに乗っています。DOMノードがページ上で維持されている限りrefコールバックのライフサイクルは継続し、DOMノードが除去されるときに終わります。DOMノードが変わったときは新しいサイクルが始まります。つまり、refコールバックのライフタイムはDOMノードのライフタイムと一致していると言えます。

このDOMノードのライフサイクルというのは、上で見たように、依存配列ではうまく表せないケースもあります。ご存じのとおり、useEffectの依存配列にはref.currentのような値は入れられません。useEffectの依存配列はあくまでレンダリング中に計算されるものであり、ref.currentはレンダリング中に参照すべきではないからです。しかし、この「ref.currentに依存する」ようなサイクルをrefコールバックならば実現できるのです。

これがrefコールバックの無二の特徴です。useEffectはuseRefと組み合わせたDOM操作に適していることもありますが、エフェクトのライフタイムはあくまでステートやレンダリングに引きずられており、まだ“React側”な感じです。一方でrefコールバックはDOM要素に密着したライフサイクルを持っていますから、useEffectよりもさらに“DOM側”のロジックを書くのに適しているのです。

注意点としては、refコールバックもクリーンアップが可能な以上、クリーンアップできる場面ではしたほうが良いということです。ご存じのとおりuseEffectは常にクリーンアップすべきですが、クリーンアップの責務を避けるためにrefコールバックに書き換えても責務は消えません。

ただし、refコールバックがよりDOMノードに密着しており、クリーンアップされたDOMノードはあとは破棄されるだけであることを踏まえると、そのDOMノードだけにしか影響を与えていないrefコールバックであれば、クリーンアップしないことの正当性が生まれます。 ref={(node) => { node?.focus(); }} みたいなものがそれに当たります(実際の実装ではもうすこし丁寧さが必要ですが)。これもまたrefコールバックの特徴と言えるでしょう。

まとめ

この記事では、React内でDOMノードを取り扱うときに使われがちなuseRef + useEffectという組み合わせに対して、それとは別の選択肢としてのrefコールバックを紹介しました。

useEffectとrefコールバックは非常に似たところがありつつ、refコールバックのほうが適した場面というのも存在しています。適切に使い分けましょう。

使い分けの助けとするべく、記事の最後ではuseEffectとrefコールバックを対比させる形で説明しました。どこが同じでどこが違うのか理解して、適切な使い分けができるようになりましょう。

既存記事の紹介

この記事と同じトピックの既存記事としては以下の記事があります。今回はもう少し説明したいことがあったので新しい記事を用意しました。

107
71
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
107
71