Reactの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 に入れて描き直します。
// ツールチップの見た目と位置。高さが分かるまでは、ボタンの上に置く
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 にした版を作りました。
高さを測る部分は、使うフックの名前だけが違います。
// 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 のページにあるとおり、このコールバックは、ブラウザが次に画面を描く前に呼ばれます。毎フレーム、ツールチップの状態を読み、前のフレームから変わったときだけ記録します。
// 毎フレーム、画面に描かれる直前のツールチップの状態を記録する
// (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 にした版の記録です。時間は、記録を始めてからの経過時間(ミリ秒)です。
=== 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 の版では、どちらの開き方でも、測る前の位置のフレームはありませんでした。
=== 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 です。

録画を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 に替えます。





