React でレイアウト測定やDOM操作をブラウザ描画前に行いたいとき、useEffect では遅すぎることがあります。そんな場面で活躍するのが useLayoutEffect です。
⚠️ パフォーマンスに関する注意
useLayoutEffect はブラウザの描画をブロックするため、過度に使用するとアプリのパフォーマンスが低下します。可能な限り useEffect を使用し、DOM測定など本当に必要な場合のみ useLayoutEffect を使用してください。
1. useLayoutEffect とは
1.1 useEffect との違い
useLayoutEffect は useEffect と同じシグネチャ(API)を持ちますが、実行タイミングが異なります。
| フック | 実行タイミング | ブロッキング |
|---|---|---|
useEffect |
ブラウザ描画後(非同期) | しない |
useLayoutEffect |
ブラウザ描画前(同期) | する |
import { useLayoutEffect, useRef, useState } from 'react';
function Tooltip() {
const ref = useRef(null);
const [tooltipHeight, setTooltipHeight] = useState(0);
useLayoutEffect(() => {
// ブラウザが画面を描画する前に実行される
const { height } = ref.current.getBoundingClientRect();
setTooltipHeight(height);
}, []);
// ...
}
1.2 なぜ useLayoutEffect が必要か
問題: ツールチップを要素の上か下に表示したい。しかし、高さを測定してから配置を決める必要がある。
useEffect を使うと:
- ツールチップを仮の位置でレンダー
- ブラウザが画面を描画(ユーザーが見える)
- 高さを測定
- 正しい位置で再レンダー
- ブラウザが再描画
結果: ユーザーにはツールチップが「ジャンプ」して見える(ちらつき)
useLayoutEffect を使うと:
- ツールチップを仮の位置でレンダー
- 高さを測定(描画前)
- 正しい位置で再レンダー
- ブラウザが画面を描画(ユーザーに見えるのはここだけ)
結果: ユーザーには最終的な正しい位置だけが見える
1.3 useLayoutEffect の API
useLayoutEffect(setup, dependencies?)
| 引数 | 説明 |
|---|---|
setup |
副作用のロジックを含む関数。クリーンアップ関数を返すことができる |
dependencies |
省略可能。依存配列。この値が変わった時にエフェクトが再実行される |
返り値: undefined
💡 useEffect と同じ API
useLayoutEffect は useEffect と全く同じ引数・返り値を持ちます。違いは実行タイミングだけです。そのため、useEffect と useLayoutEffect は簡単に切り替えられます。
2. useLayoutEffect の内部構造を徹底解剖
useLayoutEffect と useEffect は内部的に非常に似た実装を共有しています。違いはフラグと実行タイミングだけです。
2.0 全体像: useLayoutEffect の処理フロー
🎣 useLayoutEffect(フック呼び出し)
↓
📝 Effect オブジェクトを作成(HookLayout フラグ付き)
↓
📋 updateQueue に追加
↓
🎨 レンダーフェーズ完了
↓
🔧 Layout Effects 実行(同期・描画前)
↓
🖼️ ブラウザ描画
↓
⚡ Passive Effects 実行(非同期・描画後)
2.1 エントリポイント: packages/react/src/ReactHooks.js
// packages/react/src/ReactHooks.js
export function useLayoutEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
if (__DEV__) {
if (create == null) {
console.warn(
'React Hook useLayoutEffect requires an effect callback. Did you forget to pass a callback to the hook?',
);
}
}
const dispatcher = resolveDispatcher();
return dispatcher.useLayoutEffect(create, deps);
}
useEffect と全く同じパターンで、dispatcher.useLayoutEffect を呼び出しています。
2.2 コア実装: mountLayoutEffect と updateLayoutEffect
初回レンダー時の処理
// packages/react-reconciler/src/ReactFiberHooks.js
function mountLayoutEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
let fiberFlags: Flags = UpdateEffect | LayoutStaticEffect;
if (
__DEV__ &&
(currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode
) {
fiberFlags |= MountLayoutDevEffect;
}
return mountEffectImpl(fiberFlags, HookLayout, create, deps);
}
更新時の処理
// packages/react-reconciler/src/ReactFiberHooks.js
function updateLayoutEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
return updateEffectImpl(UpdateEffect, HookLayout, create, deps);
}
2.3 useEffect との実装の違い
両者の違いはフラグにあります:
// useEffect
mountEffectImpl(
PassiveEffect | PassiveStaticEffect, // Fiber フラグ
HookPassive, // Hook フラグ
create,
deps,
);
// useLayoutEffect
mountEffectImpl(
UpdateEffect | LayoutStaticEffect, // Fiber フラグ
HookLayout, // Hook フラグ
create,
deps,
);
Effect フラグの定義
// packages/react-reconciler/src/ReactHookEffectTags.js
export type HookFlags = number;
export const NoFlags = /* */ 0b0000;
export const HasEffect = /* */ 0b0001; // Effect を実行すべき
export const Insertion = /* */ 0b0010; // useInsertionEffect
export const Layout = /* */ 0b0100; // useLayoutEffect ← ここ!
export const Passive = /* */ 0b1000; // useEffect
| フック | HookFlags | 実行タイミング |
|---|---|---|
useInsertionEffect |
HookInsertion (0b0010) |
DOM 変更前 |
useLayoutEffect |
HookLayout (0b0100) |
DOM 変更後、描画前(同期) |
useEffect |
HookPassive (0b1000) |
描画後(非同期) |
2.4 実行タイミング: Layout Effects の実行
useLayoutEffect は commitLayoutEffects 関数内で同期的に実行されます。
// packages/react-reconciler/src/ReactFiberWorkLoop.js
if (subtreeHasLayoutEffects || rootHasLayoutEffect) {
const prevTransition = ReactSharedInternals.T;
ReactSharedInternals.T = null;
const previousPriority = getCurrentUpdatePriority();
setCurrentUpdatePriority(DiscreteEventPriority); // 最高優先度
const prevExecutionContext = executionContext;
executionContext |= CommitContext;
try {
// The next phase is the layout phase, where we call effects that read
// the host tree after it's been mutated. The idiomatic use case for this is
// layout, but class component lifecycles also fire here for legacy reasons.
if (enableSchedulingProfiler) {
markLayoutEffectsStarted(lanes);
}
commitLayoutEffects(finishedWork, root, lanes); // ← ここで実行!
if (enableSchedulingProfiler) {
markLayoutEffectsStopped();
}
} finally {
executionContext = prevExecutionContext;
setCurrentUpdatePriority(previousPriority);
ReactSharedInternals.T = prevTransition;
}
}
💡 DiscreteEventPriority
Layout Effects は DiscreteEventPriority(最高優先度)で実行されます。これにより、他の更新よりも優先的に処理されます。
2.5 Layout Effects の実行: commitHookLayoutEffects
// packages/react-reconciler/src/ReactFiberCommitEffects.js
export function commitHookLayoutEffects(
finishedWork: Fiber,
hookFlags: HookFlags,
) {
// At this point layout effects have already been destroyed (during mutation phase).
// This is done to prevent sibling component effects from interfering with each other,
// e.g. a destroy function in one component should never override a ref set
// by a create function in another component during the same commit.
if (shouldProfile(finishedWork)) {
startEffectTimer();
commitHookEffectListMount(hookFlags, finishedWork);
recordEffectDuration(finishedWork);
} else {
commitHookEffectListMount(hookFlags, finishedWork);
}
}
実際の Effect 実行は commitHookEffectListMount で行われます:
// packages/react-reconciler/src/ReactFiberCommitEffects.js
export function commitHookEffectListMount(
flags: HookFlags,
finishedWork: Fiber,
) {
try {
const updateQueue: FunctionComponentUpdateQueue | null =
(finishedWork.updateQueue: any);
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & flags) === flags) {
if (enableSchedulingProfiler) {
if ((flags & HookPassive) !== NoHookEffect) {
markComponentPassiveEffectMountStarted(finishedWork);
} else if ((flags & HookLayout) !== NoHookEffect) {
markComponentLayoutEffectMountStarted(finishedWork); // ← Layout Effect
}
}
// Mount
const create = effect.create;
const inst = effect.inst;
const destroy = create(); // ← セットアップ関数を実行!
inst.destroy = destroy; // クリーンアップ関数を保存
// ...
}
effect = effect.next;
} while (effect !== firstEffect);
}
} catch (error) {
// エラーハンドリング
}
}
2.6 コミットフェーズの全体像
2.7 クリーンアップの実行タイミング
Layout Effects のクリーンアップは Mutation フェーズで実行されます。これは、セットアップよりも前のフェーズです。
// packages/react-reconciler/src/ReactFiberCommitEffects.js
export function commitHookLayoutUnmountEffects(
finishedWork: Fiber,
nearestMountedAncestor: null | Fiber,
hookFlags: HookFlags,
) {
// Layout effects are destroyed during the mutation phase so that all
// destroy functions for all fibers are called before any create functions.
// This prevents sibling component effects from interfering with each other,
// e.g. a destroy function in one component should never override a ref set
// by a create function in another component during the same commit.
// ...
}
💡 クリーンアップが先に実行される理由
すべてのクリーンアップ関数が実行された後にセットアップ関数が実行されることで、兄弟コンポーネント間での干渉を防ぎます。例えば、あるコンポーネントの destroy 関数が別のコンポーネントの create 関数で設定された ref を上書きすることを防ぎます。
2.8 まとめ: useLayoutEffect の内部構造
useEffect との比較
| 項目 | useEffect | useLayoutEffect |
|---|---|---|
| Hook フラグ |
HookPassive (0b1000) |
HookLayout (0b0100) |
| Fiber フラグ | PassiveEffect |
UpdateEffect |
| 実行フェーズ | Passive Effects Phase | Layout Phase |
| 実行タイミング | 描画後(非同期) | 描画前(同期) |
| ブロッキング | しない | する |
| 優先度 | 通常 | DiscreteEventPriority |
処理フローの5ステージ
-
mountLayoutEffect / updateLayoutEffect: Effect オブジェクトを
HookLayoutフラグ付きで作成 - mountEffectImpl: Effect を updateQueue に追加
- Mutation Phase: 古い Layout Effects のクリーンアップを実行
- Layout Phase: 新しい Layout Effects のセットアップを同期的に実行
- Paint: ブラウザが画面を描画
3. 代表的ユースケース
3.1 要素のサイズ測定
function MeasuredBox() {
const ref = useRef(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
if (ref.current) {
const { width, height } = ref.current.getBoundingClientRect();
setDimensions({ width, height });
}
}, []);
return (
<div ref={ref}>
サイズ: {dimensions.width} x {dimensions.height}
</div>
);
}
3.2 ツールチップの配置
function Tooltip({ targetRect, children }) {
const ref = useRef(null);
const [tooltipHeight, setTooltipHeight] = useState(0);
useLayoutEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setTooltipHeight(height);
}, []);
// ターゲットの上に収まるかチェック
let tooltipY = targetRect.top - tooltipHeight;
if (tooltipY < 0) {
// 収まらない場合は下に表示
tooltipY = targetRect.bottom;
}
return (
<div ref={ref} style={{ top: tooltipY, left: targetRect.left }}>
{children}
</div>
);
}
3.3 スクロール位置の復元
function ChatMessages({ messages }) {
const containerRef = useRef(null);
const prevMessagesLength = useRef(messages.length);
useLayoutEffect(() => {
if (messages.length > prevMessagesLength.current) {
// 新しいメッセージが追加されたら最下部にスクロール
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
prevMessagesLength.current = messages.length;
}, [messages]);
return (
<div ref={containerRef} style={{ overflow: 'auto', height: 400 }}>
{messages.map(msg => <Message key={msg.id} {...msg} />)}
</div>
);
}
3.4 アニメーションの初期状態設定
function FadeIn({ children }) {
const ref = useRef(null);
useLayoutEffect(() => {
const el = ref.current;
// 描画前に透明に設定
el.style.opacity = '0';
el.style.transition = 'opacity 0.3s ease-in';
// 次のフレームでフェードイン開始
requestAnimationFrame(() => {
el.style.opacity = '1';
});
}, []);
return <div ref={ref}>{children}</div>;
}
3.5 フォーカス管理
function AutoFocusInput({ autoFocus }) {
const inputRef = useRef(null);
useLayoutEffect(() => {
if (autoFocus) {
// 描画と同時にフォーカスを設定
inputRef.current.focus();
}
}, [autoFocus]);
return <input ref={inputRef} />;
}
4. パフォーマンスと注意点
4.1 useLayoutEffect を避けるべきケース
// ❌ データフェッチには useEffect を使う
useLayoutEffect(() => {
fetch('/api/data').then(setData);
}, []);
// ✅ 正しい方法
useEffect(() => {
fetch('/api/data').then(setData);
}, []);
// ❌ イベントリスナーには useEffect を使う
useLayoutEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// ✅ 正しい方法
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
4.2 useLayoutEffect が必要なケース
| ユースケース | 理由 |
|---|---|
| DOM要素のサイズ測定 | 測定結果を描画に反映するため |
| ツールチップの配置 | ちらつきを防ぐため |
| スクロール位置の調整 | ジャンプを防ぐため |
| フォーカス管理 | 確実にフォーカスを設定するため |
| アニメーションの初期状態 | 初期状態を見せないため |
4.3 サーバーサイドレンダリングとの互換性
useLayoutEffect はサーバー上では動作しません。サーバーサイドレンダリング(SSR)を使用している場合、以下のエラーが発生する可能性があります:
Warning: useLayoutEffect does nothing on the server
解決策1: useEffect に置き換える
// サーバーでも動作する
useEffect(() => {
// DOM 測定
}, []);
解決策2: クライアント専用にする
function ClientOnlyTooltip(props) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <FallbackContent />;
}
return <Tooltip {...props} />;
}
解決策3: useSyncExternalStore を使用する
外部データストアとの同期が目的の場合、サーバーレンダリングをサポートする useSyncExternalStore の使用を検討してください。
5. トラブルシューティング
5.1 "useLayoutEffect does nothing on the server"
原因: サーバーサイドレンダリング中に useLayoutEffect を使用
解決策:
-
useEffectに変更する - コンポーネントをクライアント専用にする
-
useSyncExternalStoreを使用する
5.2 パフォーマンスの低下
原因: useLayoutEffect 内で重い処理を行っている
解決策:
- 本当に描画前に実行する必要があるか再検討
- 重い処理は
useEffectに移動 - 測定のみを
useLayoutEffectで行い、その他の処理はuseEffectで行う
function Component() {
const ref = useRef(null);
const [height, setHeight] = useState(0);
// 測定だけ useLayoutEffect で
useLayoutEffect(() => {
setHeight(ref.current.getBoundingClientRect().height);
}, []);
// 重い処理は useEffect で
useEffect(() => {
if (height > 0) {
doHeavyCalculation(height);
}
}, [height]);
return <div ref={ref}>...</div>;
}
5.3 無限ループ
原因: useLayoutEffect 内で state を更新し、それが依存配列に含まれている
// ❌ 無限ループ
useLayoutEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setDimensions({ height }); // state 更新
}, [dimensions]); // ← dimensions が依存配列に!
// ✅ 正しい方法
useLayoutEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setDimensions({ height });
}, []); // 空の依存配列
6. まとめ
この記事で解説した内容は、公式ドキュメントと facebook/react リポジトリの以下のファイルに基づいています:
useLayoutEffect のエントリポイント
-
packages/react/src/ReactHooks.js-
useLayoutEffectのエクスポート関数
-
コア実装
-
packages/react-reconciler/src/ReactFiberHooks.js-
mountLayoutEffect: 初回マウント時の処理 -
updateLayoutEffect: 更新時の処理
-
Effect フラグ
-
packages/react-reconciler/src/ReactHookEffectTags.js-
Layout(0b0100): useLayoutEffect のフラグ
-
Effect の実行
-
packages/react-reconciler/src/ReactFiberWorkLoop.js-
commitLayoutEffects: Layout Effects の実行タイミング
-
-
packages/react-reconciler/src/ReactFiberCommitEffects.js-
commitHookLayoutEffects: セットアップの実行 -
commitHookLayoutUnmountEffects: クリーンアップの実行
-
-
useLayoutEffectはuseEffectと同じ API だが、ブラウザ描画前に同期的に実行される - 内部的には
HookLayoutフラグを使い、コミットフェーズの Layout Phase で実行 - DOM測定、ツールチップ配置、スクロール調整など、ちらつきを防ぎたい場合に使用
- パフォーマンスに影響するため、本当に必要な場合のみ使用
- サーバーサイドレンダリングでは動作しないため注意が必要
使い分けの指針:
- デフォルトは
useEffectを使用 - DOM測定が必要で、その結果を描画に反映する場合のみ
useLayoutEffectを使用