0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Qiita Advent Calender 2025企画】React Hooks を Hackしよう!【Part8: useLayoutEffectをふかぼってみよう!】

Posted at

React でレイアウト測定やDOM操作をブラウザ描画前に行いたいとき、useEffect では遅すぎることがあります。そんな場面で活躍するのが useLayoutEffect です。

⚠️ パフォーマンスに関する注意
useLayoutEffect はブラウザの描画をブロックするため、過度に使用するとアプリのパフォーマンスが低下します。可能な限り useEffect を使用し、DOM測定など本当に必要な場合のみ useLayoutEffect を使用してください。

1. useLayoutEffect とは

1.1 useEffect との違い

useLayoutEffectuseEffect と同じシグネチャ(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 を使うと:

  1. ツールチップを仮の位置でレンダー
  2. ブラウザが画面を描画(ユーザーが見える
  3. 高さを測定
  4. 正しい位置で再レンダー
  5. ブラウザが再描画

結果: ユーザーにはツールチップが「ジャンプ」して見える(ちらつき)

useLayoutEffect を使うと:

  1. ツールチップを仮の位置でレンダー
  2. 高さを測定(描画前)
  3. 正しい位置で再レンダー
  4. ブラウザが画面を描画(ユーザーに見えるのはここだけ

結果: ユーザーには最終的な正しい位置だけが見える

1.3 useLayoutEffect の API

useLayoutEffect(setup, dependencies?)
引数 説明
setup 副作用のロジックを含む関数。クリーンアップ関数を返すことができる
dependencies 省略可能。依存配列。この値が変わった時にエフェクトが再実行される

返り値: undefined

💡 useEffect と同じ API
useLayoutEffectuseEffect と全く同じ引数・返り値を持ちます。違いは実行タイミングだけです。そのため、useEffectuseLayoutEffect は簡単に切り替えられます。

2. useLayoutEffect の内部構造を徹底解剖

useLayoutEffectuseEffect は内部的に非常に似た実装を共有しています。違いはフラグ実行タイミングだけです。

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 コア実装: mountLayoutEffectupdateLayoutEffect

初回レンダー時の処理

// 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 の実行

useLayoutEffectcommitLayoutEffects 関数内で同期的に実行されます。

// 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ステージ

  1. mountLayoutEffect / updateLayoutEffect: Effect オブジェクトを HookLayout フラグ付きで作成
  2. mountEffectImpl: Effect を updateQueue に追加
  3. Mutation Phase: 古い Layout Effects のクリーンアップを実行
  4. Layout Phase: 新しい Layout Effects のセットアップを同期的に実行
  5. 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: クリーンアップの実行


  • useLayoutEffectuseEffect と同じ API だが、ブラウザ描画前に同期的に実行される
  • 内部的には HookLayout フラグを使い、コミットフェーズの Layout Phase で実行
  • DOM測定、ツールチップ配置、スクロール調整など、ちらつきを防ぎたい場合に使用
  • パフォーマンスに影響するため、本当に必要な場合のみ使用
  • サーバーサイドレンダリングでは動作しないため注意が必要

使い分けの指針:

  • デフォルトは useEffect を使用
  • DOM測定が必要で、その結果を描画に反映する場合のみ useLayoutEffect を使用
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?