Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

useSyncExternalStore を使って常に最新のclientRectを参照する

意外とネットに記事として上がってなかったので書いた。

function useClientRectWidth<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const subscribe = useCallback((listener: () => void) => {
    if (!ref.current) return () => void 0;

    const resizeObserver = new ResizeObserver(listener);
    resizeObserver.observe(ref.current);
    return () => resizeObserver.disconnect();
  }, []);

  const width = useSyncExternalStore(
    subscribe,
    () => ref.current?.getBoundingClientRect().width ?? null,
  );

  return { ref, width };
}

使い方はこんな感じ↓

export function App() {
  const { ref, width } = useClientRectWidth<HTMLTextAreaElement>();
  return (
    <div>
      <textarea ref={ref} />
      width: {width}
    </div>
  );
}

DOMRect全体を返す場合

width単体ではなくrect全体を取得したい場合、ちょっと工夫が必要。というのも、getBoundingClientRectの返り値は常に新しいオブジェクトなので、 無限レンダリングが発生して画面が真っ白になってしまう (参照)。

これを回避するには、useSyncExternalStoreに渡す getSnapshot関数をメモ化する必要がある。 幸いDOMRectは比較的単純な構造なので、JSON.stringifyをキーにしたキャッシュを実装すればよい。

function useClientRect<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const subscribe = useCallback((listener: () => void) => {
    if (!ref.current) return () => void 0;

    const resizeObserver = new ResizeObserver(listener);
    resizeObserver.observe(ref.current);
    return () => resizeObserver.disconnect();
  }, []);

  const lastCacheKey = useRef('');
  const cache = useRef<DOMRect | null>(null);
  const rect = useSyncExternalStore(subscribe, () => {
    const rect = ref.current?.getBoundingClientRect() ?? null;
    // 前回のJSON.stringify(rect)と結果が同じならcacheを返す
    if (JSON.stringify(rect) === lastCacheKey.current) {
      return cache.current;
    }
    // 前回と異なるので、キャッシュを更新する
    lastCacheKey.current = JSON.stringify(rect);
    return (cache.current = rect);
  });

  return { ref, rect };
}