意外とネットに記事として上がってなかったので書いた。
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 }; }