【実務・中級編】 useEffectとuseLayoutEffectの使い分け – React実践ガイド

おう、みんな元気か? フロントエンドの魔術師、俺様だ。今日はReactのよくある落とし穴、特に「副作用の制御」について、古今東西の秘術を伝授してやろう。特に「`useEffect`と`useLayoutEffect`、どっちを選べばいいんだ?」って迷ってるお前ら、よく聞きな。これは単なる知識じゃねぇ、現場で生き残るための知恵だ。

`useEffect` vs `useLayoutEffect`: 描画の裏側、その深淵へ

Reactでコンポーネントの状態が更新された後、画面に描画される前に、あるいは描画された後に何か処理をしたい時、みんなはまず`useEffect`を思い浮かべるだろう。それが基本だし、ほとんどの場合、これで事足りる。

でもな、世の中そんなに甘くねぇ。特に、DOMのレイアウト(位置やサイズ)に触るような処理や、描画に即座に反映させたい、なんていう「ちょっとワガママ」な要求が出てくると、話は変わってくるんだ。そこで登場するのが`useLayoutEffect`だ。

ブラウザの描画サイクル、その秘密を暴く

まず、ブラウザがReactのコンポーネントをどうやって画面に描画してるのか、その裏側を理解することが、この二つのフックを使い分ける鍵になる。

1. Reactのレンダリング: コンポーネントの状態が更新されると、Reactは仮想DOM(Virtual DOM)を再構築する。
2. 差分検出とDOM更新: Reactは前の仮想DOMと新しい仮想DOMを比較して、実際に変更が必要なDOMノードを特定し、それらを更新する。
3. ブラウザのペイント: DOMの更新が終わると、ブラウザは画面を「ペイント」(描画)する。これが、ユーザーが実際に目にする部分だ。

ここで重要なのが、`useEffect`と`useLayoutEffect`が、このサイクルの中のいつ実行されるか、ってことなんだ。

  • `useEffect`: これはペイント後に実行される。つまり、ReactがDOMを更新し、ブラウザが画面に描画し終えた「後」に、非同期で実行されるんだ。これは、パフォーマンスに優しい。なぜなら、描画処理をブロックしないからだ。ユーザーは画面が描画されるのを待つ必要がない。
  • `useLayoutEffect`: これはペイント前に実行される。ReactがDOMを更新した後、ブラウザが画面にペイントする「前」に、同期的に実行されるんだ。ということは、このフック内の処理が長引くと、ブラウザの描画がブロックされて、ユーザーは画面の更新が遅れたように感じてしまう可能性がある。

いつ `useLayoutEffect` を使うべきか?

じゃあ、どんな時に`useLayoutEffect`を選ぶべきか? ズバリ、それは「DOMのレイアウト(サイズや位置)を読み取って、その情報に基づいて何かDOMを操作する必要がある場合」だ。

例えば、

  • 要素の幅や高さを取得して、それに応じて別の要素のスタイルを調整する。
  • スクロール位置を計算して、特定の要素にスクロールさせる。
  • 要素の計算されたスタイル(`getComputedStyle`など)を読み取って、それを元に何らかの再描画やスタイル変更を行う。

こういった処理は、描画された後に実行してしまうと、一度「古いレイアウト」が見えてしまい、その後「新しいレイアウト」に更新される、という一瞬の「ちらつき(flickering)」が発生する可能性がある。`useLayoutEffect`を使えば、このちらつきを防ぎ、ユーザーにスムーズな体験を提供できるんだ。

実践!コードで見てみよう

言葉だけじゃ分かりにくいだろう? 実際にコードを見て、その違いを肌で感じてみよう。

ケース1: 基本的なデータ取得や非同期処理(`useEffect`で十分)

これは最も一般的なケースだ。APIからデータを取得してきて、それをstateにセットする、とか、タイマーを設定する、とか。こういうのは、描画が終わった後でも全然問題ない。

import React, { useState, useEffect } from ‘react’;

function DataFetcher() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
console.log(‘useEffect: データ取得開始!’);
// 非同期処理の開始 (例: APIコール)
const fetchData = async () => {
try {
// 実際にはAPIエンドポイントを指定します
// const response = await fetch(‘https://api.example.com/data’);
// const result = await response.json();
await new Promise(resolve => setTimeout(resolve, 1000)); // 1秒待つシミュレーション
const result = { message: ‘データが取得できました!’ };
setData(result);
} catch (error) {
console.error(‘データの取得に失敗しました:’, error);
} finally {
setLoading(false);
console.log(‘useEffect: データ取得完了、またはエラー発生。’);
}
};

fetchData();

// クリーンアップ関数 (コンポーネントがアンマウントされる時や、依存配列が変わる前に実行)
return () => {
console.log(‘useEffect: クリーンアップ実行。タイマーやAPIコールの中断など。’);
// ここでタイマーのクリアや、未完了のAPIリクエストをキャンセルする処理を書く
};
}, []); // 依存配列が空なので、コンポーネントのマウント時に一度だけ実行されます

if (loading) {
return

データを読み込み中…

;
}

return (

データ表示

{data ? data.message : ‘データがありません。’}

);
}

export default DataFetcher;

このコードでは、`useEffect`がコンポーネントのマウント後に実行される。`console.log`を見てみると、コンポーネントが表示された後で「useEffect: データ取得開始!」と表示されるはずだ。これは、ユーザーは「データを読み込み中…」という表示をすぐに見て、その後にデータが画面に現れる、という流れになる。パフォーマンス的にも問題ない。

ケース2: DOMのレイアウト操作(`useLayoutEffect`の出番)

さて、ここからが本番だ。要素の幅を取得して、その幅に応じて別の要素のスタイルを変える、という例を見てみよう。

まず、`useEffect`でこれをやるとどうなるか、見てみよう。

import React, { useState, useEffect, useRef } from ‘react’;

function LayoutExampleInefficient() {
const [width, setWidth] = useState(0);
const myElementRef = useRef(null);

useEffect(() => {
console.log(‘useEffect: DOMの幅を測定します…’);
if (myElementRef.current) {
// 描画後にDOMの幅を取得
const elementWidth = myElementRef.current.getBoundingClientRect().width;
setWidth(elementWidth);
console.log(`useEffect: 測定された幅: ${elementWidth}px`);
// ここで取得した幅を元に何か処理をすると、ちらつきの原因になることがある
}
}, []); // コンポーネントマウント時に一度だけ実行

// 実際にDOMに適用されるスタイル
const dynamicStyle = {
backgroundColor: ‘lightblue’,
marginTop: ’20px’,
// 測定した幅の半分をマージンに設定してみる(例として)
marginLeft: `${width / 2}px`,
padding: ’10px’,
border: ‘1px solid blue’,
display: ‘inline-block’ // 幅を正確に測定するために必要
};

return (

useEffectでのレイアウト操作(非効率な例)

これは、要素の幅を測定し、それに応じてスタイルを変更する例です。

この緑のボックスの幅を測定します。

{/ 測定した幅に応じてスタイルが変わる要素 /}

この水色のボックスの左マージンは、緑のボックスの幅によって動的に計算されます。

(※ この例では、緑のボックスの幅は固定なので、実際にはあまり変化しませんが、
もし緑のボックスの幅が可変だった場合、useEffectだと一瞬本来のスタイルで表示され、
その後にleft-marginが計算されて動く、というちらつきが発生し得ます。)

);
}

export default LayoutExampleInefficient;

このコードだと、コンポーネントが最初にレンダリングされる時、`myElementRef`で参照されている緑のボックスの幅はまだ不明確な状態だ。`useEffect`は描画後に実行されるため、一度「幅が計算される前の状態」で水色のボックスが表示され、その後に`useEffect`が実行されて幅が計算され、水色のボックスの`marginLeft`が更新される。もし、この`marginLeft`がユーザーに見える部分に影響を与えるようなレイアウトだったら、一瞬「あれ?」ってなる可能性があるんだ。

では、`useLayoutEffect`に置き換えてみよう。

import React, { useState, useLayoutEffect, useRef } from ‘react’;

function LayoutExampleEfficient() {
const [width, setWidth] = useState(0);
const myElementRef = useRef(null);

useLayoutEffect(() => {
console.log(‘useLayoutEffect: DOMの幅を測定します…’);
if (myElementRef.current) {
// ペイント前にDOMの幅を取得
const elementWidth = myElementRef.current.getBoundingClientRect().width;
setWidth(elementWidth);
console.log(`useLayoutEffect: 測定された幅: ${elementWidth}px`);
// ここで取得した幅を元にDOM操作をしても、ちらつきは発生しにくい
}
}, []); // 依存配列が空なので、コンポーネントのマウント時に一度だけ実行

// 実際にDOMに適用されるスタイル
const dynamicStyle = {
backgroundColor: ‘lightblue’,
marginTop: ’20px’,
// 測定した幅の半分をマージンに設定してみる(例として)
marginLeft: `${width / 2}px`,
padding: ’10px’,
border: ‘1px solid blue’,
display: ‘inline-block’ // 幅を正確に測定するために必要
};

return (

useLayoutEffectでのレイアウト操作(効率的な例)

これは、要素の幅を測定し、それに応じてスタイルを変更する例です。

この緑のボックスの幅を測定します。

{/ 測定した幅に応じてスタイルが変わる要素 /}

この水色のボックスの左マージンは、緑のボックスの幅によって動的に計算されます。

(※ useLayoutEffectを使うことで、緑のボックスの幅が確定した後に水色のボックスのスタイルが適用されるため、
ちらつきがなく、よりスムーズに見えます。)

);
}

export default LayoutExampleEfficient;

`useLayoutEffect`に置き換えたことで、ReactはDOMを更新した後、ブラウザがペイントする前に`useLayoutEffect`内のコードを実行する。つまり、緑のボックスの幅が確定した「直後」に、その幅を元にした水色のボックスのスタイル(`marginLeft`)が計算され、それがブラウザにペイントされるんだ。結果として、ユーザーは最初から正しいレイアウトの要素を見ることになり、ちらつきがなくなる。

`console.log`を見てみると、「useLayoutEffect: DOMの幅を測定します…」が「useLayoutEffect: 測定された幅: XXXpx」よりも先に表示されるはずだ。これは、ペイント前に処理が完了している証拠だ。

依存配列の重要性:無限ループの悪夢から逃れる

さて、`useEffect`だろうが`useLayoutEffect`だろうが、依存配列(第二引数)の扱いを間違えると、それはもう「地獄」だ。無限ループに陥ったり、意図しないタイミングで処理が走ったり。

依存配列に何も指定しない場合(`[]`)、そのエフェクトはコンポーネントのマウント時とアンマウント時に一度だけ実行される。

依存配列に値(stateやpropsなど)を指定した場合、その値が変更されるたびにエフェクトが再実行される。

ここで注意すべきは、エフェクト内でstateを更新する場合だ。もし、エフェクト内でstateを更新し、その更新されたstateが依存配列に含まれていると、エフェクトが実行 → state更新 → エフェクト再実行、という無限ループに陥る。

例:無限ループに陥るコード(絶対にやめろ!)

import React, { useState, useEffect } from ‘react’;

function InfiniteLoopExample() {
const [count, setCount] = useState(0);

useEffect(() => {
console.log(‘useEffect: countが’, count, ‘に更新されました。’);
// !!! 警告: ここでcountを更新すると無限ループに陥ります !!!
// setCount(count + 1); // これは絶対にやってはいけない!
}, [count]); // countが変更されるたびに実行されるが、 setCount(count + 1) でcountが更新され、また実行される

return (

無限ループの例(見ないで!):

Count: {count}

{/ /}

);
}

// export default InfiniteLoopExample; // このコンポーネントをレンダリングするとブラウザが固まる可能性が高い

このコードをもし実行したら、ブラウザが重くなったり固まったりするだろう。`count`が更新されるたびに`useEffect`が走り、その中で`setCount`が呼ばれて`count`がさらに更新される、という悪夢だ。

安全なstate更新のルール

  • エフェクト内でstateを更新する場合は、そのstateを依存配列に含めない。
  • または、state更新関数(`setCount`)を依存配列に含める。Reactのstate更新関数は安定しているので、無限ループの原因にはならない。

import React, { useState, useEffect } from ‘react’;

function SafeUpdateExample() {
const [count, setCount] = useState(0);

// 方法1: setCount関数を依存配列に含める (推奨)
// setCount関数は常に同じ参照を持つため、無限ループの原因にはならない
useEffect(() => {
console.log(‘useEffect: countが’, count, ‘に更新されました。’);
// 1秒後にcountをインクリメントする
const timerId = setTimeout(() => {
setCount(prevCount => prevCount + 1); // 関数型アップデートを使うと、依存配列にcountを含める必要がなくなる
console.log(‘setTimeout: countを更新しました。’);
}, 1000);

return () => {
console.log(‘useEffect: クリーンアップ。タイマーをクリアします。’);
clearTimeout(timerId); // タイマーをクリア
};
}, []); // 依存配列は空。一度だけタイマーを設定する。
// もしcountを依存配列に含めたいなら、setCount(count + 1)ではなくsetCount(prevCount => prevCount + 1)を使う。

return (

安全なstate更新の例:

Count: {count}

);
}

export default SafeUpdateExample;

この`SafeUpdateExample`では、`useEffect`の依存配列を空にしている。そして、`setTimeout`内で`setCount(prevCount => prevCount + 1)`という「関数型アップデート」を使っている。これにより、`count`というstateの値そのものに依存せず、常に最新のstateから1つ増やせる。これは`useEffect`が再実行される必要がないため、無限ループを防ぐのに非常に効果的だ。

まとめ:賢く使い分けるためのチェックリスト

さあ、今日の話のまとめだ。

  • `useEffect`:
  • いつ使う?: 非同期処理(API通信、タイマー設定)、DOMの読み取り(ただしレイアウトに影響しないもの)、イベントリスナーの設定など、描画後に実行しても問題ない副作用。
  • メリット: 描画をブロックしないため、パフォーマンスが良い。
  • 注意点:DOMのレイアウトに即座に影響を与える処理をここで行うと、ちらつきの原因になることがある。
  • `useLayoutEffect`:
  • いつ使う?: DOMのサイズや位置、スクロール位置などのレイアウト情報を読み取り、その情報に基づいて描画前にDOMを操作する必要がある場合。ちらつきを避けたい時。
  • メリット: 描画前に処理が完了するため、ちらつきを防ぎ、ユーザーにスムーズな体験を提供できる。
  • デメリット: 同期的に実行されるため、処理が重いと描画がブロックされ、パフォーマンスに影響を与える可能性がある。多用は禁物。
  • 依存配列:
  • 空配列 `[]`: マウント時とアンマウント時に一度だけ実行。
  • 値(state, props)を指定: その値が変更されるたびに実行。
  • state更新時の注意: エフェクト内でstateを更新する場合は、関数型アップデート (`setState(prevState => …)`) を使うか、`setState`関数自体を依存配列に含める。エフェクト内で更新されるstateそのものを依存配列に含めると、無限ループの危険がある。

Reactは奥が深い。今日の話は、その中でも特に「副作用」という、地味だけどめちゃくちゃ重要な部分に焦点を当てた。`useEffect`と`useLayoutEffect`を正しく使い分けることで、あなたのアプリケーションはより堅牢で、ユーザーフレンドリーになるはずだ。

現場で迷ったら、この話を思い出してくれ。お前ならできる。健闘を祈る!

コメント

タイトルとURLをコピーしました