🦁

ref で解決する最新状態の参照問題

2024/07/08に公開

以前からたまに使ってはいたがちゃんと言語化できていなかったのでまとめる。

Making setInterval Declarative with React Hooks — overreacted

setIntervalを使ってカウントアップするコンポーネント。これは間違いの例。

import { useEffect, useState } from "react";

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

  useEffect(() => {
    function tick() {
      setCount(count + 1);
    }

    const timerId = setInterval(tick, 1000);

    return () => clearInterval(timerId);
  }, []);

  return <h1>{count}</h1>;
}
  • useEffect内のcountは依存配列内に含まれないのでsetCountのcountはずっと同じ値を表示し続ける。この場合は、countはずっと0のママなので表示される値は1から変わらない。

では、依存配列にcountを設定すると、どうなるのか。一見動作には問題ないように思えるが…

  1. 不要なレンダリングが多くなる。countが変更されるたびにuseEffectが走ってしまう。
  2. インターバルの頻繁な再設定。アプリケーションの反応が遅くなる可能性がある。
  3. コンポーネントが長時間マウントされている場合、intervalの設定と破棄が繰り返されるのでパフォーマンスに影響を与える可能性。

といったところ。

setCount((prevCount) => prevCount + 1) の利用で解決もするが、カウントの増分や間隔を動的に変更したい場合など、この実装では難しくなる。動くは動くのだが違和感が拭えない。

そして、おなじくrefによって解決することができる。

import { useEffect, useRef, useState } from "react";

export function Counter() {
  const [count, setCount] = useState(0);
  const timerRef = useRef();

  function callback() {
    setCount((prevCount) => prevCount + 1);
  }

  useEffect(() => {
    timerRef.current = callback;
  });

  useEffect(() => {
    function tick() {
      timerRef.current();
    }

    const timerId = setInterval(tick, 1000);

    return () => clearInterval(timerId);
  }, []);

  return (
    <>
      <h1>{count}</h1>
    </>
  );
}

refの値を変更しても再描画されない機能を利用している。
レンダーごと、つまりcountが更新されるたびにrefのコールバック関数を差し替えている。
この関数では最新のcountが参照されていることになる。
なのでuseEffectの依存配列が空でもちゃんと最新のcountが利用されている。

useEffectの依存配列が空なので、mount時とunmount時にしかこのuseEffectは発火しない。

このアイデアはintersectionObserverを利用時にも使うことができる。

一部コードの抜粋。

  const handleIntersection = (entries: IntersectionObserverEntry[]) => {
    ...なにかステートを利用する処理
  };

  useEffect(() => {
    intersectionCallback.current = handleIntersection;
  });

  useEffect(() => {
    const observer = new IntersectionObserver((entries) =>
      intersectionCallback.current(entries)
    );
    observer.observe(endOfPageRef.current!);
  }, []);

intersectionObserverが発火するタイミングで最新のステートを使ったコールバック関数を実行することが可能となる。

コールバックが必要なブラウザAPIや外部SDKの関数を実行するときに、refの存在を思い出すと良いかもしれない。

株式会社トゥーアール

Discussion