左上隅から正しい位置へパッと移動するツールチップを作ったことはありませんか? あるいは、正しいサイズに落ち着く前に、一瞬だけ間違ったサイズで表示されるモーダルは? その一瞬の不具合が「レイアウトのちらつき(layout flicker)」です。これは、ReactがDOMを読み取り、修正を計算し、ステートを更新する一方で、ブラウザがすでにピクセルを画面に描画し始めているときに発生します。一般的な解決策は、useEffectuseLayoutEffectに置き換えることです。この入れ替えは有効ですが、ブラウザのパイプライン内で各フックが正確にいつ実行されるかを理解している場合に限られます。

ブラウザのパイプライン:Render、Commit、Paint

Reactは3つの異なるステージでコンポーネントを更新します。**render(レンダリング)フェーズでは、Reactは仮想DOMを構築(または再構築)し、差分(diff)を計算します。まだ実際のピクセルの変更は行われません。これはメモリ内で行われる純粋な計算です。次にcommit(コミット)**フェーズが続き、Reactはそれらの変更を実際のDOMノードに適用します。スタイルの更新、ノードの挿入や削除、テキストの変更が行われます。

その後、ブラウザが処理を引き継ぎます。**paint(ペイント)**フェーズでは、ブラウザのレンダリングエンジンがレイアウトのジオメトリを計算し、画面にピクセルを描画します。このシーケンスは厳格です。ブラウザはペイントを行う前にレイアウトを完了させる必要があり、ユーザーが新しい内容を見る前にペイントを完了させる必要があります。コミットとペイントの間のギャップはミリ秒単位ですが、それは確実に存在し、useEffectuseLayoutEffectが分かれる境界線となります。

なぜ useEffect がちらつきを引き起こすのか

useEffectは非同期に実行され、ブラウザがすでに画面をペイントした後に実行されるようスケジュールされます。DOMが更新され、ピクセルが描画された後に、Reactが再び介入してエフェクトを実行します。

例えば、ボタンの下にドロップダウンメニューを表示するとしましょう。useEffectの中でbuttonRef.current.getBoundingClientRect()を呼び出し、正しい上部(top)と左部(left)の座標を計算してステートに保存します。useEffectはペイント後に実行されるため、ブラウザはすでにドロップダウンをデフォルトの位置(例えば top: 0, left: 0)に描画してしまっています。そのペイントが行われた後に初めて、エフェクトがステートを更新します。Reactは修正された座標をコミットし、ブラウザは再度ペイントを行います。ユーザーには「間違った位置」が表示された後に「正しい位置」が表示されるという、2フレーム分の動きが見えます。この視覚的な「パッ」という動きこそが、誰もが避けようとしている「ちらつき」です。

データ取得、APIコール、アナリティクスのトラッキング、イベントリスナーの設定などの場合、この遅延は問題になりません。ユーザーは、アナリティクスのビーコンがペイントの数ミリ秒後に送信されたかどうかなど気にしません。実際、視覚に関係のない作業をペイント後まで遅らせることで、初期レンダリングのレスポンスを維持できます。しかし、レイアウトに依存する修正については、useEffectでは単にタイミングが遅すぎるのです。

useLayoutEffect がペイントをブロックする方法

useLayoutEffectは同期的に実行されます。ReactがDOMを操作した直後、かつブラウザがレイアウトを計算したりピクセルをペイントしたりする前に実行されます。これはペイントのパイプラインを完全にブロックします。

もし同じドロップダウンの計測をuseLayoutEffect内で行うと、シーケンスが変わります。Reactが最初のDOM更新をコミットし、レイアウトエフェクトを実行すると、ステートの更新によって同期的な再レンダリングがトリガーされます。Reactが修正された座標をコミットし、その後に初めてブラウザがペイントを行います。ユーザーには、最初から正しい状態の1フレームだけが見えます。

このブロッキング動作は、機能であると同時にリスクでもあります。useLayoutEffectは処理が完了するまでブラウザのペイントを妨げるため、その中で重い計算を行うとUIがフリーズします。たとえ数十ミリ秒のペイントのブロックであっても、ユーザーにはジャンク(動作のぎこちなさ)として感じられます。そのため、Reactのドキュメントでは、まずはuseEffectから使い始め、どうしても許容できないちらつきが観察された場合にのみuseLayoutEffectへ移行するように明記されています。

各フックの使い分け

ロジックの大部分はuseEffectに属します。以下の用途で使用してください:

  • APIからのデータ取得
  • サブスクリプションやイベントリスナーの設定
  • アナリティクスイベントの送信
  • レイアウトを即座に読み取ったり変更したりしない副作用全般

useLayoutEffectは、ユーザーがフレームを見る前にDOMを読み取り、書き戻す必要がある操作のために取っておきましょう:

  • 幅、高さ、スクロール位置などの要素の寸法の計測
  • ツールチップ、ポップオーバー、コンテキストメニューの座標計算
  • 視覚的な位置がレンダリングされたジオメトリに依存する場合の、目に見えるレイアウトシフトの防止

どちらを選ぶべきか迷ったら、デフォルトでuseEffectを選択してください。視覚的な不安定さに気づいたときのみ、useLayoutEffectに切り替えます。このルールを守るだけで、ほとんどのReactアプリケーションをスムーズに動作させることができます。

サーバーサイドレンダリング(SSR)の落とし穴

Next.js、Remix、または React をサーバー側でレンダリングするフレームワークを使用している場合、useLayoutEffect に関する警告が表示されます。サーバーには DOM が存在しないため、このフックは測定対象を持ちません。React は、ブラウザ環境を想定していたものの、見つからなかったことを警告します。ハイドレーション中、サーバーでレンダリングされたマークアップとクライアント側の最初の意図されたレンダリングが異なる可能性があるため、この不一致は微妙なバグを引き起こすこともあります。

標準的な解決策は、環境に基づいて適切なエフェクトを選択するアイソモーフィックなフックを使用することです。

const useIsomorphicLayoutEffect =
  typeof window !== 'undefined' ? useLayoutEffect : useEffect;

DOM ノードを測定する必要があるが、サーバーレンダリング中に実行される可能性があるコンポーネントには、このラッパーを使用してください。これにより、警告を抑制し、サーバーの出力を一貫した状態に保つことができます。

パフォーマンスとベストプラクティス

useLayoutEffect はペイントをブロックするため、フックの本体は可能な限り軽量に保ってください。レイアウト値を読み取り、補正を計算し、それを書き戻します。フックの中でデータの取得、大きなオブジェクトの解析、または重いアルゴリズムの実行を行わないでください。ここに重いコードがあると、メインスレッドが停止し、インターフェースがフリーズしたように感じられます。

要素を測定するときは、document.getElementById ではなく React の ref を使用してください。ref はコンポーネントのインスタンスに紐付けられており、クエリのテクニックを使わずに再レンダリングを乗り越え、ポータルや条件付きレンダリングでも確実に動作します。グローバルな ID 検索はコンポーネントのカプセル化を壊し、まさにそれが必要な瞬間に null を返す可能性があります。

ほとんどすべてのサイドエフェクトにおいて、デフォルトとしては useEffect が適切です。これにより、ブラウザは中断することなくペイントでき、データ、イベント、および外部との同期をクリーンに処理できます。useLayoutEffect は、ペイント前にレイアウトを読み取り、書き戻すという特定の課題に対する専門的なツールです。両者のタイミングの違いをマスターすれば、ちらつき(flicker)に悩まされるのではなく、それを未然に防げるようになります。

重要なポイント: まずはすべてにおいて useEffect から始めてください。ツールチップやモーダルが、正しい位置に修正される前に一瞬間違った場所に表示されるのを見たとき、それが合図です。useLayoutEffect に切り替え、DOM を測定し、レイアウトを調整して、ブラウザに一度だけ正しくペイントさせましょう。