ReactのuseEffectとuseLayoutEffectの違いと使い分け

​​React​Playwright,TypeScript,useEffect,useLayoutEffect

useLayoutEffect は、ブラウザが画面を描く前に必ず動きます。useEffect は、描いたあとに動くことがあります。要素の大きさを測ってから位置を決める表示では、useEffect だと「測る前の位置」が一瞬だけ画面に出て、ちらついて見えることがあります。useLayoutEffect で測れば、測ったあとの位置だけが画面に出ます。

筆者は検証環境で、高さを測って位置を決めるツールチップを2つのフックでそれぞれ作り、Chromium で開いたときに、どのフレームで何が描かれたかを記録しました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で本番用(NODE_ENV=production)に変換し、Chromium 141 を Playwright 1.56.1 で操作しました。Chromium は画面のないモード(ヘッドレス)で動かしています(Playwright の既定)。

ポイント

useEffect で測って state を変えると、測る前の位置が1フレーム描かれることがあります。クリックがきっかけでも起きました。useLayoutEffect では、測る前の位置は一度も描かれませんでした。そのかわり、useLayoutEffect の中の処理と、そこで変えた state の再描画が終わるまで、ブラウザは画面を描けません。

違いは、ブラウザが画面を描く前に動くかどうか

公式リファレンスの useLayoutEffect の説明では、useLayoutEffect は「ブラウザが画面を描き直す前に動く useEffect」とされています。React が DOM を書き換えたあと、ブラウザが描く前に、useLayoutEffect に渡した関数が呼ばれます。その中で変えた state の再描画も、描く前に済みます(公式リファレンスの useLayoutEffect の注意点)。

useEffect のほうは、公式リファレンスの useEffect の注意点によると、クリックのような操作がきっかけでなければ、ふつうはブラウザが画面を描いてから動きます。クリックがきっかけのときは描く前に動くことがありますが、その中で変えた state の処理は、描いたあとに回ることがあるとも書かれています。

高さを測ってツールチップを置くコード

公式リファレンスにある、ツールチップの高さを測る例にならって、ボタンの上にツールチップを出し、上に入らなければ下に置くコンポーネントを作りました。最初の描画では高さが分からないので、高さを 0 とみなしてボタンの上に置きます。そのあとエフェクトで高さを測り、state に入れて描き直します。

tsx
// ツールチップの見た目と位置。高さが分かるまでは、ボタンの上に置く
function TooltipBox({ label, anchorTop, anchorBottom, height, boxRef, children }: BoxProps) {
  log(`${label}: 描画(高さ=${height ?? '未測定'})`);
  slowDown(SLOW_MS);
  const gap = 8;
  let top = anchorTop - (height ?? 0) - gap;
  if (height !== null && top < 0) {
    // 上に入らないので、ボタンの下に置く
    top = anchorBottom + gap;
  }
  return (
    <div ref={boxRef} className="tooltip" data-measured={height !== null} style={{ top }}>
      {children}
    </div>
  );
}

slowDown(SLOW_MS) は、描画にわざと時間をかける処理です。指定したミリ秒のあいだ、何もしないループを回します。違いを目で見て分かるようにするためです。公式リファレンスにある、2つのフックを比べる例でも、同じように描画を遅くしています。この記事では、SLOW_MS を 200 にした版と 0 にした版を作りました。

高さを測る部分は、使うフックの名前だけが違います。

tsx
// useEffect で高さを測る版
export function TooltipWithEffect(props: Props) {
  const ref = useRef<HTMLDivElement>(null);
  const [height, setHeight] = useState<number | null>(null);
  useEffect(() => {
    log('useEffect: 高さを測る');
    setHeight(ref.current!.getBoundingClientRect().height);
  }, []);
  return <TooltipBox label="useEffect" height={height} boxRef={ref} {...props} />;
}

// useLayoutEffect で高さを測る版
export function TooltipWithLayoutEffect(props: Props) {
  const ref = useRef<HTMLDivElement>(null);
  const [height, setHeight] = useState<number | null>(null);
  useLayoutEffect(() => {
    log('useLayoutEffect: 高さを測る');
    setHeight(ref.current!.getBoundingClientRect().height);
  }, []);
  return <TooltipBox label="useLayoutEffect" height={height} boxRef={ref} {...props} />;
}

測る前のツールチップには、CSS でピンクの枠を付けました(data-measured="false" のとき)。画面にピンクの枠が出たら、測る前の位置が描かれたということです。

描かれたフレームを requestAnimationFrame で記録する

どのフレームで何が描かれたかは、requestAnimationFrame で調べました。MDN の requestAnimationFrame のページにあるとおり、このコールバックは、ブラウザが次に画面を描く前に呼ばれます。毎フレーム、ツールチップの状態を読み、前のフレームから変わったときだけ記録します。

tsx
// 毎フレーム、画面に描かれる直前のツールチップの状態を記録する
// (requestAnimationFrame のコールバックのあと、ブラウザはそのときの DOM を描く)
let last = '';
function watchFrames() {
  const states = (['effect', 'layout'] as Mode[]).map((m) => {
    const t = document.querySelector<HTMLElement>(`#card-${m} .tooltip`);
    if (!t) return `${m}=なし`;
    const where = t.dataset.measured === 'true' ? '測ったあとの位置' : '測る前の位置';
    return `${m}=${where}(top=${t.style.top})`;
  });
  const now = states.join(' / ');
  if (now !== last) log(`[フレーム] ${now}`);
  last = now;
  requestAnimationFrame(watchFrames);
}
requestAnimationFrame(watchFrames);

ツールチップは、ボタンのクリックと、タイマー(setTimeout)の2通りで開きました。タイマーで開くのは、クリックのような操作がきっかけでない場合を見るためです。

useEffect では、測る前の位置が描かれた

SLOW_MS を 200 にした版の記録です。時間は、記録を始めてからの経過時間(ミリ秒)です。

text
=== useEffect・タイマーで開いた場合
    4ms useEffect: 描画(高さ=未測定)
  205ms [フレーム] effect=測る前の位置(top=4px) / layout=なし
  206ms useEffect: 高さを測る
  207ms useEffect: 描画(高さ=36)
  407ms [フレーム] effect=測ったあとの位置(top=58px) / layout=なし
=== useEffect・クリックで開いた場合
   36ms useEffect: 描画(高さ=未測定)
  236ms useEffect: 高さを測る
  238ms [フレーム] effect=測る前の位置(top=4px) / layout=なし
  239ms useEffect: 描画(高さ=36)
  439ms [フレーム] effect=測ったあとの位置(top=58px) / layout=なし

タイマーで開いたときは、最初の描画のあと、まず測る前の位置(top=4px、ボタンに重なる位置)のフレームが描かれました。useEffect が動いたのはそのあとです。測ったあとの位置(top=58px、ボタンの下)が描かれたのは、さらに200ミリ秒ほどあとでした。

クリックで開いたときは、useEffect はフレームより先に動きました。それでも、setHeight による再描画はフレームのあとに回り、測る前の位置が描かれました。公式リファレンスの注意点にある、描く前に動いても、中で変えた state の処理は描いたあとになることがある、という説明のとおりの動きです。

useLayoutEffect の版では、どちらの開き方でも、測る前の位置のフレームはありませんでした。

text
=== useLayoutEffect・タイマーで開いた場合
    2ms useLayoutEffect: 描画(高さ=未測定)
  203ms useLayoutEffect: 高さを測る
  203ms useLayoutEffect: 描画(高さ=36)
  404ms [フレーム] effect=なし / layout=測ったあとの位置(top=58px)
=== useLayoutEffect・クリックで開いた場合
   42ms useLayoutEffect: 描画(高さ=未測定)
  242ms useLayoutEffect: 高さを測る
  243ms useLayoutEffect: 描画(高さ=36)
  443ms [フレーム] effect=なし / layout=測ったあとの位置(top=58px)

同じ画面を Chromium で録画し、GIF にしました。上のカードが useEffect、下のカードが useLayoutEffect です。

上の useEffect のカードでボタンを押すと、ピンクの枠の付いたツールチップが一瞬ボタンに重なって出てから、ボタンの下に移る。下の useLayoutEffect のカードでは、ツールチップは最初からボタンの下に出て、ピンクの枠は出ない
ツールチップを開いたときの見え方(上が useEffect、下が useLayoutEffect。ピンクの枠は測る前の位置、ピンクの丸はクリックした位置)

録画を1秒25コマで切り出して調べたところ、ピンクの枠が写っていたのは、上のカードの5コマ(約0.2秒)だけでした。下のカードには、1コマも写っていませんでした。

描画が速いと、タイマーで開いたときはちらつかなかった

SLOW_MS を 0 にした版と 200 にした版で、それぞれの開き方を20回ずつ試し、測る前の位置が描かれた回数を数えました。

開き方 SLOW_MS=0 SLOW_MS=200
useEffect・タイマー 0/20 20/20
useEffect・クリック 20/20 20/20
useLayoutEffect・タイマー 0/20 0/20
useLayoutEffect・クリック 0/20 0/20

描画を遅くしない版でタイマーで開くと、useEffect でも、20回とも測る前の位置は描かれませんでした。フレームごとの記録を見ると、次のフレームが来る前に、エフェクトと再描画が済んでいました。描画を遅くしない版でクリックで開くと、20回とも測る前の位置が1フレームだけ描かれました。

useEffect のちらつきは、描画にかかる時間や、次のフレームまでのタイミングによって、出たり出なかったりします。手元で一度試して出なかったからといって、ほかの環境でも出ないとは限りません。今回の環境では、クリックで開いたときは毎回出ました。

useLayoutEffect は、画面を描くのを待たせる

useLayoutEffect にも弱点があります。上の SLOW_MS=200 の記録(タイマーで開いた場合)では、ツールチップが初めて画面に出るまで、useEffect の版は約200ミリ秒(測る前の位置)、useLayoutEffect の版は約400ミリ秒でした。測ったあとの位置が出たのは、どちらも約400ミリ秒です。useLayoutEffect の版では、2回の描画が終わるまで、ツールチップは画面に出ませんでした。途中の状態を見せない代わりに、最初の表示が遅れます。

公式リファレンスの useLayoutEffect の注意点でも、useLayoutEffect の中の処理と、そこで変えた state の更新は、ブラウザが画面を描くのを止めるとしています。使いすぎるとアプリが遅くなるので、できるだけ useEffect を使うように勧めています。

補足

useLayoutEffect はサーバーでの描画(SSR)では動きません。公式リファレンスのトラブルシューティングでは、サーバーには要素の位置や大きさ(レイアウト)の情報がないため、位置はクライアントで JavaScript が動いたあとに変わると説明しています。この記事では、サーバーでの描画は試していません。

使い分けは、公式リファレンスの、エフェクトで画面がちらつくときの説明のとおりです。ほとんどのエフェクトは useEffect で書きます。ツールチップの位置決めのように、要素を測って見た目を変え、その途中の状態を見せたくないときだけ useLayoutEffect に替えます。

​Posted by 86