Next.js

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>;
}

runningtrueならタイマーを開始し、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がサーバー上で動くわけではありません。

関連記事と公式資料

Effectが必要かを見直す

表示用の計算を分ける

propsやstateから計算できる表示値を、Effectで別のstateへコピーする必要がないか検討します。外部システムとの同期と、描画中にできる計算を分けると、余分な更新を減らせます。

解除と再登録を試す

タイマーやイベントは、部品の取り外しと再表示でも重複しないことを確認します。開発時のStrict Modeで繰り返し実行されても整合するよう、開始した処理に対応する後始末を用意してください。

Next.jsとは・ルーターの違い・学習順を確認する

y.
WRITTEN BY

y_ymo10

SEの部屋で、JavaScript・TypeScript・React.js・Next.jsの開発ノートを公開しています。

ほかの開発ノートを読む →