react-useのuseMeasureを使うとDOMサイズを監視する際に一瞬0が出てしまうので新しくhooksを作った
始めに
Reactで汎用的なhooksを使う際は react-use をよく使っていて、その中で useMeasure というDOMサイズを監視するhooksを使っていましたが、一瞬0のサイズで描画される問題がありました。
以下のgifは分かりやすいようにrender前に遅延処理を挟むことで0が表示されていることが確認できます。
またMutationObserverを使ってテキストの変更を監視しても、width: 0
が出力されてから実際のサイズが出力されていることが確認できました。
ちなみにテキスト変更の監視コードは以下のものを使用しました。詳細が気になる方はアコーディオンを開いてご確認ください。
テキストの変更監視コンポーネント
import { FC, ReactNode, useState, useCallback } from 'react';
export type MutationWatcherProps = {
children: ReactNode;
};
export const MutationWatcher: FC<MutationWatcherProps> = ({ children }) => {
const [isShow, setIsShow] = useState(false);
const ref = useCallback((element: HTMLElement | null) => {
if (element == null) {
return;
}
const observer = new MutationObserver((mutationList) => {
console.log(mutationList);
console.log(element.textContent);
});
observer.observe(element, {
childList: true,
characterData: true,
characterDataOldValue: true,
subtree: true,
});
return () => {
observer.disconnect();
};
}, []);
return (
<div>
<button
onClick={() => {
console.log('isShow:', !isShow);
setIsShow(!isShow);
}}
>
{isShow ? 'hide' : 'show'}
</button>
<div ref={ref} style={{ marginTop: 5 }}>
{isShow && children}
</div>
</div>
);
};
consoleに出力された内容を詳しく見ると、以下のようなものが出力されていました。最初にchildListにDOMが追加されたことが検知され、その後テキストデータが更新されています。
一瞬なので基本的にはそこまで気にはなりませんが、遅延処理を差し込むと目視でもハッキリ分かるようにrenderに時間がかかるくらい処理が増えてくると気になってしまうリスクが出てくるため、できればこの現象は回避したいと思っていました。とはいえDOMサイズの取得は一度描画しないと無理だと思っていたので諦めていました。
しかし色々調べたところ、useLayoutEffect
のような、描画前に計算するステップがあるようで、そこでサイズを取得することで0が描画されずに計算されたサイズを初回から表示されるように調整することができました。
この記事ではなぜuseMeasure
では0で描画がされる瞬間が存在するのか、そしてその問題をどう解消したかをまとめました。
検証コード
今回検証で使ったコードはStackBlizに上げていますので、詳細のコードや動作を見たい方はこちらをご覧ください。
react-useのuseMeasureだと一瞬0が表示されてしまう理由
useMeasure
のコードは以下になっています。ざっくり挙動を説明すると、DOM要素はuseStateで管理し、このstateをuseIsomorphicLayoutEffect
で変更を検知して、ResizeObserverに登録しています。
useIsomorphicLayoutEffect
とはSSR時のフォールバックを考慮したhooksで、中身はuseLayoutEffect
かuseEffect
をブラウザかnodeかで切り替えているものでした。
なお、このフォールバックは今では不要そうです。
useLayoutEffect
が使われているので描画前に計算できているように見えますが、肝心のサイズの計算結果の保存がResizeObserverのコールバックで行われているため、ワンステップ遅れてしまいます。これによってサイズの更新が確定する前に一度描画されてしまい、その後サイズデータが反映される挙動になっていました。
従って以下のように書くだけで0で描画されてしまうのは回避できます。
function useMeasure<E extends Element = Element>(): UseMeasureResult<E> {
const [element, ref] = useState<E | null>(null);
const [rect, setRect] = useState<UseMeasureRect>(defaultState);
const observer = useMemo(
() =>
new (window as any).ResizeObserver((entries) => {
if (entries[0]) {
const { x, y, width, height, top, left, bottom, right } = entries[0].contentRect;
setRect({ x, y, width, height, top, left, bottom, right });
}
}),
[]
);
useIsomorphicLayoutEffect(() => {
if (!element) return;
+ // 現在のDOMサイズを登録する
+ const domRect = element.getBoundingClientRect();
+ const { x, y, width, height, top, left, bottom, right } = domRect
+ setRect({ x, y, width, height, top, left, bottom, right });
observer.observe(element);
return () => {
observer.disconnect();
};
}, [element]);
return [ref, rect];
}
これで0で描画される問題は解決するのですが、新たな問題が出てしまいます。それはgetBoundingClientRect
とResizeObserverのコールバックに含まれるcontentRect
で取得されるサイズが微妙に違うことです。DevToolで確認すると分かりますが、実はpaddingを除いたサイズを取得しています。
一方、getBoundingClientRect
はpaddingとborderも含めたサイズを取得するため、paddingとborderのスタイルを当てている場合はサイズがズレてしまうという致命的な問題が発生してしまいます。残念ながらclientWidth
など他のサイズを取得するプロパティも大体paddingやborderを含むものでそれらを除いたサイズを取得するのは簡単ではないため、現状のhooksに手を入れるのは難しそうでした。
一瞬0が出る問題を回避したhooksを実装する
上記の問題を踏まえて、新しくDOMサイズを監視するhooksを作りたいと思います。取得するサイズを統一するため、今回はelement.offsetWidth
, element.offsetHeight
を監視前と監視中のどちらも参照してwidthとheightだけ保存するようにしました。また描画前に計算をする仕組みはuseLayoutEffect
だけでなくref callbackでも出来たので、そちらで試してみました。React 19だとクリーンアップも書けるのでこちらの方がすっきりした実装になると思います。
これらをまとめると、以下のようなコードになりました。
import { useCallback, useState } from 'react';
type DOMSize = {
width: number;
height: number;
};
/** DOMサイズの初期値 */
const INITIAL_DOM_SIZE: DOMSize = {
width: 0,
height: 0,
};
/**
* 0表示のチラつきを抑えたDOMサイズ監視hooks
*/
export const useDOMSize = () => {
const [size, setSize] = useState(INITIAL_DOM_SIZE);
const ref = useCallback((element: HTMLElement | null) => {
if (element == null) {
return;
}
// 監視前のサイズを保存する
setSize({
width: element.offsetWidth,
height: element.offsetHeight,
});
const observer = new ResizeObserver(() => {
setSize({
width: element.offsetWidth,
height: element.offsetHeight,
});
});
observer.observe(element);
return () => {
observer.disconnect();
};
}, []);
return {
ref,
size,
};
};
これでMutationObserverで2回検知されることなく、一括で変更されるようになりました。MutationRecordのoldValueにwidth: 0
が入っているので一瞬0で描画されていそうに見えていますが、これはあくまで内部計算のために裏で描画したもので、実際に画面に表示するものは最後のconsoleに出力されているwidth: 340
になります。
終わりに
以上がreact-useのuseMeasureだと一瞬0が出てしまう原因と、それを回避したhooksの実装内容でした。1フレームの差をどうするかという内容だったためかなりReactの挙動を知っていないと原因の特定や解決に苦労しましたが、無事解決できて良かったです😊
ReactでDOMサイズの監視に苦労している方の参考になれば幸いです。
Discussion