useLayoutEffect

落とし穴

useLayoutEffect はパフォーマンスを低下させる可能性があります。可能な限り useEffect を使用することを推奨します。

useLayoutEffectuseEffect の一種ですが、ブラウザが画面を再描画する前に実行されます。

useLayoutEffect(setup, dependencies?)

リファレンス

useLayoutEffect(setup, dependencies?)

ブラウザが画面を再描画する前にレイアウトの計測を行うために useLayoutEffect を呼び出します。

import { useState, useRef, useLayoutEffect } from 'react';

function Tooltip() {
const ref = useRef(null);
const [tooltipHeight, setTooltipHeight] = useState(0);

useLayoutEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setTooltipHeight(height);
}, []);
// ...

さらに例を見る

引数

  • setup: エフェクトのロジックが記述された関数です。このセットアップ関数は、オプションでクリーンアップ関数を返すことができます。コンポーネントが初めて DOM に追加されると、React はセットアップ関数を実行します。依存配列 (dependencies) が変更された再レンダー時には、React はまず古い値を使ってクリーンアップ関数(あれば)を実行し、次に新しい値を使ってセットアップ関数を実行します。コンポーネントが DOM から削除された後、React はクリーンアップ関数を最後にもう一度実行します。

  • 省略可能 dependencies: setup コード内で参照されるすべてのリアクティブな値のリストです。リアクティブな値には、props、state、コンポーネント本体に直接宣言されたすべての変数および関数が含まれます。リンタが React 用に設定されている場合、すべてのリアクティブな値が依存値として正しく指定されているか確認できます。依存値のリストは要素数が一定である必要があり、[dep1, dep2, dep3] のようにインラインで記述する必要があります。React は、Object.is を使った比較で、それぞれの依存値を以前の値と比較します。この引数を省略すると、エフェクトはコンポーネントの毎回のレンダー後に再実行されます。

返り値

useLayoutEffectundefined を返します。

注意点

  • useLayoutEffect はフックであるため、コンポーネントのトップレベルやカスタムフック内でのみ呼び出すことができます。ループや条件文の中で呼び出すことはできません。これが必要な場合は、新しいコンポーネントを抽出し、その中にエフェクトを移動させてください。

  • Strict Mode が有効な場合、React は本物のセットアップの前に、開発時専用のセットアップ+クリーンアップサイクルを 1 回追加で実行します。これは、クリーンアップロジックがセットアップロジックと鏡のように対応しており、セットアップで行われたことを停止または元に戻していることを保証するためのストレステストです。問題が発生した場合は、クリーンアップ関数を実装します

  • 依存配列の一部にコンポーネント内で定義されたオブジェクトや関数がある場合、エフェクトが必要以上に再実行される可能性があります。これを修正するには、オブジェクト型および関数型の不要な依存値を削除します。また、エフェクトの外部に state の更新非リアクティブなロジックを抽出することもできます。

  • エフェクトはクライアント上でのみ実行されます。サーバレンダリング中には実行されません。

  • useLayoutEffect 内のコードと、そこでスケジュールされたすべての state 更新は、ブラウザによる画面の再描画をブロックします。過度に使用すると、アプリが遅くなります。可能な限り useEffect を使用してください


使用法

ブラウザが画面を再描画する前にレイアウトを測定する

ほとんどのコンポーネントは、何をレンダーするかを決定するために、画面上での位置やサイズを知る必要はありません。単に JSX を返します。その後、ブラウザがそのレイアウト(位置とサイズ)を計算し、画面を再描画します。

しかし、それだけでは不十分な場合もあります。例えば、ある要素をホバーしたときに近くに表示されるツールチップを想像してみてください。十分なスペースがある場合、ツールチップは要素の上に表示されるべきですが、収まらない場合は下に表示されるとします。ツールチップを正しい最終位置にレンダーするためには、その高さ(つまり、上部に収まるかどうか)を知る必要があります。

これを行うには、2 パスでレンダーを行う必要があります:

  1. ツールチップを(位置が間違っていても良いので)どこかにレンダーします。
  2. ツールチップの高さを測定し、ツールチップを配置する場所を決定します。
  3. ツールチップを正しい場所で再度レンダーします。

これらすべては、ブラウザが画面を再描画する前に行わなければなりません。ユーザにツールチップが移動するのを見せたくないのです。ブラウザが画面を再描画する前にレイアウトの測定を行うために useLayoutEffect を呼び出します。

function Tooltip() {
const ref = useRef(null);
const [tooltipHeight, setTooltipHeight] = useState(0); // You don't know real height yet

useLayoutEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setTooltipHeight(height); // Re-render now that you know the real height
}, []);

// ...use tooltipHeight in the rendering logic below...
}

動作をステップごとに説明します。

  1. Tooltip は初回は tooltipHeight = 0 でレンダーされます(そのため、ツールチップは間違った位置に配置される可能性があります)。
  2. React はそれを DOM に配置し、useLayoutEffect のコードを実行します。
  3. useLayoutEffect はツールチップの内容の高さを測定し、即時の再レンダーをトリガします。
  4. Tooltip は実際の tooltipHeight で再度レンダーされます(ツールチップが正しく配置されます)。
  5. React が DOM 上で更新を行い、最終的にブラウザがツールチップを表示します。

以下のボタンにホバーしてみて、ツールチップが収まるかどうかに応じて位置を調整する様子を観察してください。

import { useRef, useLayoutEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import TooltipContainer from './TooltipContainer.js';

export default function Tooltip({ children, targetRect }) {
  const ref = useRef(null);
  const [tooltipHeight, setTooltipHeight] = useState(0);

  useLayoutEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setTooltipHeight(height);
    console.log('Measured tooltip height: ' + height);
  }, []);

  let tooltipX = 0;
  let tooltipY = 0;
  if (targetRect !== null) {
    tooltipX = targetRect.left;
    tooltipY = targetRect.top - tooltipHeight;
    if (tooltipY < 0) {
      // It doesn't fit above, so place below.
      tooltipY = targetRect.bottom;
    }
  }

  return createPortal(
    <TooltipContainer x={tooltipX} y={tooltipY} contentRef={ref}>
      {children}
    </TooltipContainer>,
    document.body
  );
}

Tooltip コンポーネントを 2 パスで(初回は tooltipHeight0 で、2 回目は実際に測定した高さで)レンダーする必要があるにもかかわらず、最終結果だけが表示されることに注目してください。これが、この例で useEffect の代わりに useLayoutEffect が必要な理由です。詳細な違いは以下の通りです。

useLayoutEffect vs useEffect

1/2:
useLayoutEffect はブラウザの再描画をブロックする

React は、useLayoutEffect 内のコードとその中でスケジュールされたすべての state 更新が、ブラウザが画面を再描画する前に処理されることを保証します。これにより、ツールチップをレンダーし、測定し、再度レンダーするという処理を、ユーザが最初の余分なレンダーに気付かないように行うことができます。言い換えると、useLayoutEffect はブラウザの描画をブロックします。

import { useRef, useLayoutEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import TooltipContainer from './TooltipContainer.js';

export default function Tooltip({ children, targetRect }) {
  const ref = useRef(null);
  const [tooltipHeight, setTooltipHeight] = useState(0);

  useLayoutEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setTooltipHeight(height);
  }, []);

  let tooltipX = 0;
  let tooltipY = 0;
  if (targetRect !== null) {
    tooltipX = targetRect.left;
    tooltipY = targetRect.top - tooltipHeight;
    if (tooltipY < 0) {
      // It doesn't fit above, so place below.
      tooltipY = targetRect.bottom;
    }
  }

  return createPortal(
    <TooltipContainer x={tooltipX} y={tooltipY} contentRef={ref}>
      {children}
    </TooltipContainer>,
    document.body
  );
}

補足

2 パスでレンダーしてブラウザをブロックすることはパフォーマンスを低下させます。できる限り避けてください。


トラブルシューティング

useLayoutEffect does nothing on the server” というエラーが出る

useLayoutEffect の目的は、コンポーネントがレンダーのためにレイアウト情報を使用できるようにすることです。

  1. 初期コンテンツをレンダーする。
  2. ブラウザが画面を再描画する前にレイアウトを測定する。
  3. 読み取ったレイアウト情報を使用して最終コンテンツをレンダーする。

あなた、またはあなたのフレームワークがサーバレンダリングを使用している場合、React アプリは初期表示のためにサーバ上で HTML にレンダーされます。これにより、JavaScript コードがロードされる前に初期 HTML を表示できます。

問題は、サーバ上にはレイアウト情報がないことです。

先ほどの例では、Tooltip コンポーネント内での useLayoutEffect の呼び出しにより、高さに応じて自身を正しくコンテンツの上または下に配置することができていました。初期のサーバ HTML の一部として Tooltip をレンダーしようとすると、これは不可能になります。サーバ上ではまだレイアウトが存在しないからです! したがって、サーバ上でレンダーしても、JavaScript がロードされ実行された際に、クライアント上で位置の「ジャンプ」が起こってしまいます。

通常、レイアウト情報に依存するようなコンポーネントは、サーバ上でレンダーする必要はありません。例えば、初期レンダー中に Tooltip を表示することはおそらく無意味です。これはクライアントでのユーザ操作に応じてトリガされるものだからです。

しかし、この問題に直面している場合、いくつかの選択肢があります。

  • useLayoutEffectuseEffect に置き換えます。これにより React に対して、初期レンダー結果の表示を描画をブロックせずに行ってよいことを伝えます(元の HTML はエフェクトが実行される前に表示されるからです)。

  • あるいは、コンポーネントをクライアント専用としてマークします。これにより React に対して、サーバレンダリング中に最も近い <Suspense> バウンダリまで、コンテンツをロード中というフォールバック(例えば、スピナやグリマー)に置き換えるように指示します。

  • あるいは、useLayoutEffect を持つコンポーネントをハイドレーションの後にのみレンダーします。false に初期化されたブーリアン型の isMounted という state を保持しておき、useEffect の呼び出し内で true に設定します。そしてレンダーロジックは return isMounted ? <RealContent /> : <FallbackContent /> のようにします。サーバ上およびハイドレーション中は、ユーザは useLayoutEffect を呼び出さない FallbackContent を見ることになります。その後、React はそれをクライアント専用の RealContent に置き換えますが、そこには useLayoutEffect の呼び出しを含むことができます。

  • コンポーネントを外部データストアと同期させており、レイアウト測定とは異なる理由で useLayoutEffect を使っている場合、代わりに useSyncExternalStore を検討してみてください。これはサーバレンダリングをサポートしています