useEffect vs useLayoutEffect: ブラウザ描画の深淵を覗き、Reactアプリの描画パフォーマンスを極限まで高める
Reactの世界に足を踏み入れた君たち、ようこそ。特に、単に動くだけのUIから、ユーザーの心を掴む、いや、むしろユーザー体験そのものをデザインするレベルのアプリケーションを目指す、そんな志の高いエンジニア諸君へ。今回は、Reactにおける副作用の制御、特に `useEffect` と `useLayoutEffect` の使い分けという、一見地味ながらも、アプリケーションの質を根底から左右する重要なテーマに、とことん深掘りしていく。
公式ドキュメントの文字面をなぞるだけの解説では決して得られない、ブラウザのレンダリングサイクル、DOMの挙動、そしてReactの内部処理の機微に触れながら、君たちのコードをより洗練させ、パフォーマンスの限界を引き出すための知見を、余すところなく共有しよう。
描画の波に乗るか、波を制するか? `useEffect` と `useLayoutEffect` の本質
まず、この二つのHookがなぜ存在するのか、その根源的な違いを理解することが全てだ。どちらもコンポーネントのレンダリング後に実行される「副作用」を扱うためのものだが、その「タイミング」が決定的に異なる。
- `useEffect`: これは、ブラウザが画面を描画し終えた後に実行される。いわゆる「非同期」な実行タイミングだ。Reactは、コンポーネントをレンダリングし、DOMの更新をブラウザに適用した後、ユーザーに画面を見せる。その後で `useEffect` のコールバック関数が実行される。
この非同期性が、Reactアプリケーションの全体的なレンダリングパフォーマンスを向上させる鍵となる。なぜなら、`useEffect` 内の処理が多少重かったとしても、それはブラウザの描画処理をブロックしないからだ。ユーザーは画面の表示を待つことなく、すぐにアプリを操作できるようになる。これは、ユーザー体験(UX)という観点から見れば、非常に重要な利点だ。
- `useLayoutEffect`: こちらは、ReactがDOMの変更を検知し、ブラウザが実際に画面に描画する直前に、同期的に実行される。これは、ブラウザがピクセルを画面に描画する前に、君たちのコードがDOMにアクセスしたり、スタイルを調整したりする機会を与えるということだ。
同期的な実行という性質上、`useLayoutEffect` 内の処理が重いと、ブラウザの描画がブロックされてしまう。つまり、ユーザーは画面の更新を待たされることになる。このブロックは、特に複雑なDOM操作や、レイアウト計算を伴う処理で顕著になり、ちらつき(flickering)やパフォーマンスの低下を引き起こす可能性がある。
描画タイミングの違いがもたらす、実践的な影響
このタイミングの違いは、単なる理論上の話ではない。実際のアプリケーション開発において、深刻なバグやパフォーマンスの問題に直結する。
例えば、コンポーネントが表示された直後に、その要素のサイズを計測して、別の要素の位置を調整したい場合を考えてみよう。
もし `useEffect` を使うとどうなるか?
1. ReactがDOMを更新する。
2. ブラウザが画面に描画する。
3. `useEffect` が実行され、要素のサイズを計測する。
4. 計測結果に基づいて、別の要素の位置を調整する。
5. ここで、Reactは再度DOMの更新を必要とする。
6. ReactがDOMを更新する。
7. ブラウザが画面に描画する。
このように、`useEffect` では2回のレンダリングと描画が発生する可能性がある。最初の描画では要素が一時的に間違った位置に表示され、すぐに正しい位置に移動する。この一連の挙動は、ユーザーにとっては「ちらつき」として認識され、非常に残念なUXを生み出す。
一方、`useLayoutEffect` を使うと、このプロセスはこうなる。
1. ReactがDOMを更新する。
2. `useLayoutEffect` が実行される(ブラウザ描画前)。
3. `useLayoutEffect` 内で、要素のサイズを計測し、別の要素の位置を調整する。
4. Reactが(必要であれば)DOMの変更を検知し、再レンダリングする。
5. ブラウザが、最終的な状態のDOMを一度だけ描画する。
`useLayoutEffect` を使うことで、DOMの計測や操作をブラウザの描画サイクルに食い込ませ、1回の描画で正しい状態をユーザーに見せることができる。これにより、ちらつきを防ぎ、よりスムーズなユーザー体験を提供できるのだ。
`useLayoutEffect` を「選ぶべき」基準:DOM操作の聖域
では、具体的にどのような場合に `useLayoutEffect` を採用すべきだろうか? その基準は非常にシンプルだ。
「コンポーネントのレンダリング結果に直接影響を与える、DOMの計測や操作が必要な場合」
これが `useLayoutEffect` を使うべき、ほぼ唯一の理由と言える。具体的には、以下のようなシナリオが考えられる。
- 要素のサイズや位置の取得と、それに伴うスタイルの動的な変更:
例えば、ツールチップの表示位置を、それが依存する要素の真下に動的に調整する場合。
あるいは、スクロール位置に応じて要素の表示/非表示を切り替えたり、固定位置を調整したりする場合。
- SVG要素の属性の動的な更新:
Canvas APIのような、直接的なDOM操作を伴うライブラリをReactコンポーネント内でラップする場合。
- アニメーションライブラリとの連携:
`requestAnimationFrame` を利用したネイティブなアニメーションを、DOMの初期描画後に即座に適用したい場合。
これらのケースでは、DOMの初期状態を正確に把握し、それに即した変更を加えることが、正しい表示やアニメーションを実現するために不可欠だ。そして、その変更はブラウザの描画に反映される前に完了している必要がある。
import React, { useState, useRef, useLayoutEffect } from ‘react’;
function Tooltip({ targetRef, children }) {
const [position, setPosition] = useState({ top: 0, left: 0 });
const tooltipRef = useRef(null);
// useLayoutEffect を使用して、ブラウザ描画前に位置を計算・設定する
useLayoutEffect(() => {
if (targetRef.current && tooltipRef.current) {
const targetRect = targetRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
// target の真下に表示するための位置を計算
const top = targetRect.bottom + 5; // target の下端から少し下に
const left = targetRect.left;
// 画面の右端からはみ出さないように調整 (簡易的な例)
const adjustedLeft = Math.min(left, window.innerWidth – tooltipRect.width – 10);
setPosition({ top: top, left: adjustedLeft });
}
// 依存配列に targetRef.current が含まれると、target の変更時に再実行される
// ただし、getBoundingClientRect はDOMの状態に依存するため、
// 依存配列には直接的なstateやpropsを渡すことが多い。
// ここでは、targetRef.current の変化を検知するために、
// 実際には targetRef.current が変化したことを示す何らかのトリガーが必要になる場合もある。
// 例: parentComponent で targetRef の参照が変わるたびに、
// この Tooltip コンポーネントに key を与えるなど。
// 今回はシンプルに、初回マウント時と target が更新されたと仮定して実行されることを想定。
}, [targetRef.current]); // targetRef.current の変化を監視 (ただし、これは参照の変更に依存)
return (
{children}
);
}
// 親コンポーネントの例
function App() {
const [showTooltip, setShowTooltip] = useState(false);
const buttonRef = useRef(null);
const toggleTooltip = () => {
setShowTooltip(!showTooltip);
};
return (
{showTooltip && (
This is a dynamic tooltip!
)}
This content is below the button.
);
}
export default App;
このコードでは、ボタン(`targetRef`)の真下にツールチップを表示しようとしている。`useLayoutEffect` を使うことで、ボタンのレンダリング完了後、かつブラウザが画面を描画する前に、ボタンの `getBoundingClientRect()` を取得し、ツールチップの `top` と `left` スタイルを計算して設定している。これにより、ツールチップは最初から正しい位置に表示され、ちらつきが発生しない。
`useEffect` を「選ぶべき」基準:パフォーマンスへの配慮と非同期処理の王道
では、`useLayoutEffect` の出番がない場合はどうだろうか? ほとんどの場合、`useEffect` を使うべきだ。
`useEffect` を使うべき主な理由は、レンダリングパフォーマンスへの影響を最小限に抑えること、そして非同期処理との親和性だ。
- APIからのデータ取得と状態更新: サーバーからデータを取得し、コンポーネントの状態を更新する処理は、典型的な副作用であり、`useEffect` で行うのが自然だ。これは非同期処理であり、ブラウザの描画をブロックするべきではない。
- イベントリスナーの設定と解除: DOMイベントリスナーを設定する場合も、`useEffect` で行うのが一般的だ。ブラウザ描画後にリスナーが設定されても、ユーザー体験に悪影響はない。
- タイマー処理: `setTimeout` や `setInterval` を使用する処理も、非同期であり `useEffect` が適している。
- サードパーティライブラリの初期化: DOM要素に紐づくライブラリ(例: Chart.js、Mapライブラリなど)を初期化する場合でも、初期描画が完了してからライブラリを適用する方が、ちらつきを防げる場合が多い。ただし、ライブラリによってはDOMの初期状態に即座にアクセスする必要があるため、その場合は `useLayoutEffect` を検討する。
非同期競合とクリーンアップの重要性
`useEffect` で非同期処理を行う場合、避けて通れないのが非同期競合(Race Condition)の問題だ。コンポーネントがアンマウントされた後や、依存配列の値が変更されて `useEffect` が再実行される前に、前の非同期処理が終わってしまうことがある。
これを防ぐための最も古典的かつ効果的な手段が、クリーンアップ関数だ。
import React, { useState, useEffect } from ‘react’;
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const isMounted = useRef(true); // コンポーネントのマウント状態を追跡
useEffect(() => {
// コンポーネントがマウントされた状態を true にする
isMounted.current = true;
setLoading(true); // 新しい userId で取得開始したらローディング表示
setUser(null); // 古いユーザーデータをクリア
const fetchUser = async () => {
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
const data = await response.json();
// コンポーネントがまだマウントされているかチェック
if (isMounted.current) {
setUser(data);
setLoading(false);
}
} catch (error) {
console.error(“Failed to fetch user:”, error);
if (isMounted.current) {
setLoading(false); // エラー時もローディングを解除
}
}
};
fetchUser();
// クリーンアップ関数: コンポーネントがアンマウントされるか、
// 依存配列の値が変更されて useEffect が再実行される前に実行される
return () => {
isMounted.current = false; // コンポーネントがアンマウントされたことを記録
// ここで、もし進行中の非同期処理(例: fetch API の AbortController)を
// キャンセルする処理を書くこともできる。
// 例: controller.abort();
};
}, [userId]); // userId が変更されたら再実行
if (loading) {
return
;
}
if (!user) {
return
;
}
return (
{user.name}
Email: {user.email}
);
}
// 親コンポーネントで userId を切り替える例
function App() {
const [currentUserId, setCurrentUserId] = useState(1);
return (
);
}
export default App;
この例では、`fetchUser` 関数が非同期で実行される。もし `userId` が急速に変化した場合、前の `fetchUser` が完了する前に新しい `fetchUser` が開始される可能性がある。
クリーンアップ関数 (`return () => { … };`) は、コンポーネントがアンマウントされる際、または `userId` が変更されて `useEffect` が再実行される直前に実行される。ここで `isMounted.current = false;` とすることで、前の非同期処理が完了しても、その結果を状態としてセットしようとした際に `isMounted.current` が `false` であることを確認し、`setUser` や `setLoading` を実行しないようにしている。これにより、メモリリークや予期せぬ状態の更新を防ぐことができる。
パフォーマンス最適化とメモリ効率の観点から
`useLayoutEffect` を乱用すると、ブラウザの描画をブロックし、パフォーマンスのボトルネックとなりうる。特に、頻繁に更新されるリストや、大量のDOM要素を持つコンポーネントで `useLayoutEffect` を多用すると、スクロールがカクついたり、UIの応答性が著しく低下する可能性がある。
逆に、`useEffect` を適切に使うことは、レンダリング負荷を軽減し、アプリケーション全体の応答性を高める上で非常に重要だ。非同期処理を `useEffect` に任せることで、UIのレンダリングスレッドを解放し、ユーザーがスムーズに操作できる状態を維持できる。
メモリ効率の観点からも、クリーンアップ関数は極めて重要だ。イベントリスナーやタイマー、あるいは進行中の非同期処理を適切に解除しないと、コンポーネントがアンマウントされた後もそれらがメモリ上に残り続け、メモリリークを引き起こす。これは、長期間稼働するアプリケーションでは深刻な問題になりうる。
まとめ:君のコードに、より深い知性を
`useEffect` と `useLayoutEffect` の使い分けは、Reactアプリケーションの堅牢性とパフォーマンスを両立させるための、まさに「アーキテクトの判断」が問われる部分だ。
- DOMの計測や操作が、ブラウザの描画に影響を与える(または、ちらつきを防ぐために描画前に完了させる必要がある)場合は、迷わず `useLayoutEffect` を選べ。
- それ以外の場合、特にAPI通信やイベントリスナー、タイマー処理など、非同期処理を伴う副作用は、パフォーマンスのために `useEffect` を使うのが鉄則だ。
そして、どちらのHookを使うにせよ、クリーンアップ関数を怠らないこと。これは、メモリリークを防ぎ、非同期競合によるバグを回避するための、エンジニアの責務だ。
これらの知識を武器に、君たちのReactアプリケーションは、単に機能するだけでなく、ユーザーに快適な体験を提供する、真に高品質なものへと進化していくだろう。ブラウザの描画サイクルという深淵を理解し、その上で最適なHookを選択する。そこに、君たちのコードが持つべき、より深い知性と、圧倒的なパフォーマンスが宿るのだ。
さあ、君のコードを、次のレベルへ引き上げよう。

コメント