Next.jsのuseEffect実践:依存配列・後始末・SSRでの注意点
Next.jsのuseEffectをタイマーとブラウザイベントの例で解説。依存配列、クリーンアップ、Strict Mode、サーバー描画、非同期処理の注意点を学べます。
useEffectは、Reactの画面とブラウザAPI・タイマー・外部サービスなどを同期するHookです。画面の描画が確定した後に処理を開始し、依存値の変更や画面の破棄に合わせて後片付けを行います。単に「描画後に何でも実行する場所」と考えると、余分な更新や通信を増やしがちです。
この記事はTypeScriptとPages Routerを前提にし、React 18・Next.js 13で使える例を紹介します。EffectはサーバーでのHTML生成時には実行されません。
タイマーで開始と後片付けを学ぶ
タイマーを開始して後片付けする
ファイル:src/components/ElapsedSeconds.tsx
import { useEffect, useState } from 'react';
type Props = { running: boolean };
export default function ElapsedSeconds({ running }: Props) {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
if (!running) return;
const timer = window.setInterval(() => {
setSeconds(current => current + 1);
}, 1000);
return () => window.clearInterval(timer);
}, [running]);
return <p>タイマーのカウント: {seconds}</p>;
}
runningがtrueならタイマーを開始し、falseへの変更時やコンポーネントの破棄時には、前のEffectが返したクリーンアップ関数で停止します。[running]は「再実行してよい回数」ではなく、Effectが参照するリアクティブな値を示す依存配列です。
この例はタイマーの呼び出しを数えます。バックグラウンドタブなどではタイマーが遅れるため、厳密な経過時間の計測には時刻の差分などを使ってください。
ページで開始と停止を試す
ファイル:src/pages/timer.tsx
import { useState } from 'react';
import ElapsedSeconds from '../components/ElapsedSeconds';
export default function TimerPage() {
const [running, setRunning] = useState(true);
return (
<main>
<h1>Effectとクリーンアップ</h1>
<ElapsedSeconds running={running} />
<button type="button" onClick={() => setRunning(current => !current)}>
{running ? '停止' : '再開'}
</button>
</main>
);
}
npm run devで/timerを開き、停止中に数値が増えないこと、再開後に続きから増えることを確認します。別ページへ移動して戻ったときも、以前のタイマーが残って二重に進まないことが大切です。
依存配列とStrict Modeの意味
依存配列を省略したEffectは、描画が確定するたびに実行されます。空配列の[]は、そのEffectがリアクティブな値に依存していないことを示します。どちらの場合も、コンポーネントが実際に作り直されれば初期化が行われます。
開発時のStrict Modeでは、問題を見つけるために開始・クリーンアップ・再開始が追加で行われます。そのため、[]を「必ず一度しか実行しない保証」と説明するのは正確ではありません。イベント購読・タイマーなどは、何度開始と停止を繰り返しても整合するように設計します。
状態を読む代わりにsetSeconds(current => current + 1)を使うことで、例のタイマーはsecondsを依存配列へ追加せずに最新の保留中の値を更新できます。依存関係の警告を消すためだけに必要な値を配列から外すと、古い値を参照する原因になります。
ブラウザのイベントを購読する
ファイル:src/components/WindowWidth.tsx
import { useEffect, useState } from 'react';
export default function WindowWidth() {
const [width, setWidth] = useState<number | null>(null);
useEffect(() => {
const measure = () => setWidth(window.innerWidth);
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, []);
return <p>画面幅: {width === null ? '確認中' : `${width}px`}</p>;
}
サーバーとブラウザの最初の描画を同じ「確認中」にし、Effectでブラウザの幅を読み取ります。addEventListener()とremoveEventListener()へ同じ関数を渡して、購読を解除しています。単なる見た目の切り替えなら、JavaScriptで幅を測る前にCSSのメディアクエリで実現できるかも確認しましょう。
通信やユーザー操作は目的に合わせて配置する
Effect自身をasync関数にすると、クリーンアップの代わりにPromiseを返してしまいます。通信が必要ならEffect内で非同期関数を定義し、必要に応じてAbortControllerや古い結果を無視する仕組みで、終了済みの画面へ結果を反映しないようにします。
購入・投稿などのユーザー操作に対応する処理は、通常はクリックや送信のイベントハンドラーに置きます。表示用データをpropsから計算するだけなら、描画中に計算できることもあります。Effectを使う前に、何と同期する必要があるのかを確認してください。
App Routerとの違い
App RouterではEffectを使うコンポーネントをClient Componentの境界内に置きます。境界ファイルの先頭へ'use client';を指定します。Client ComponentでもEffectがサーバー上で動くわけではありません。
関連記事と公式資料
- useStateの使用方法と注意点
- useContextの使用方法と注意点
- React公式:useEffect
- React公式:Synchronizing with Effects
- Next.js公式:Server and Client Components
Effectが必要かを見直す
表示用の計算を分ける
propsやstateから計算できる表示値を、Effectで別のstateへコピーする必要がないか検討します。外部システムとの同期と、描画中にできる計算を分けると、余分な更新を減らせます。
解除と再登録を試す
タイマーやイベントは、部品の取り外しと再表示でも重複しないことを確認します。開発時のStrict Modeで繰り返し実行されても整合するよう、開始した処理に対応する後始末を用意してください。