🎃

ref.current を使う処理は callback ref で実装するとよい

2021/07/08に公開約1,200字4件のコメント

よくこんなコード書いてないですか

  const ref = useRef(null);
  
  useEffect(() => {
    if(ref.current) {
      ...
    }
  }, [ref.current]);
  
  return (
    <div ref={ref} .../>
  );

これだとマウント時にはレンダリングされない要素に ref を渡した場合に動かなかったりします

return (
  {isShown && <div ref={ref}/>}
);

なぜなのか

ref.current の変更では再レンダリングが起きないので、ref.current を useEffect の第二引数に渡しても再レンダリングが起きるまで useEffect で書いた処理は走ってくれません。

また ref.current は mutable 故に想定外の変更が起こりうるので、それを useEffect でハンドリングするのは大変です。

どうするべきか

useRef を使わずに callback ref を定義します。
クラスコンポーネントで古いですがReactの公式ドキュメントでも紹介されています。
https://reactjs.org/docs/refs-and-the-dom.html#callback-refs

以下はレンダリング時に scrollIntoView するサンプルです。

  // const SomeElementScrollIntoView = () => {
  const scrollTargetRef = useCallback((element: HTMLDivElement | null) => {
    if(element != null) {
      element.scrollIntoView({ behavior: 'smooth' });
    }
  }, []);
  
  return (
    <div ref={scrollTargetRef}>
      ...
    </div>
  );
}

callback ref をDOM要素に渡すと DOM が作成、削除される時に呼び出され、その DOM を受け取って処理することができます。なので先程の useEffect の例のように DOM が渡されたときに処理が走らなかったりすることがありません。またDOMがレンダリングされるタイミングで呼び出させるために依存リストに処理に不要な状態を列挙して対応するなんて方法もありますが、DOMがレンダリングされる条件を変更された際に依存リストも漏れなく対応させないと壊れるなんてこともあります。

当方マサカリ大歓迎です。良いReactライフを。

Discussion

本題ではないのですが、 useCallbackRef の実装に関して、 DOMへの変更/cleanup処理は useCallback ではなく useEffect を使い、 useCallbackRefsetCurrent をreturnすると実装の意図がわかりやすくなりそうな気がします。

React 18 では useEffect の意味が変わるようなので、今回の用途だと useCallback が良いと思います。

参考:
React 18 alpha版発表まとめ

そもそもこれ、そんなに問題でしょうか?
あまりぱっと問題になりそうなユースケースが思いつかなかったので、具体例をご教示お願いします。

先の例でいえば、isShown を useEffect の deps に入れてやれば更新タイミングの検知自体は可能なので、なんでrefの変化で無理やり検知するコードを書くのかが分かってないです…
(useEffect の deps に ref.current を書く代わりにこういうことができる、と提示するのがこの記事の主題なのでしょうか?)

確かに今回の簡単な例では isShown を dependencies list に入れれば動きますが、eslint-plugin-react-hooks の react-hooks/exhaustive-deps に反しているのがひとつ、
またその useEffect の処理の意図を知らない誰かが DOMをレンダリングする条件を isShown から isShown && otherCondition などに変更した時に動作しなくなるので個人的に好ましくないと思いました。

ログインするとコメントできます