📝

スクロール復元の実装で学んだ、useEffectを連鎖させてはいけない理由

に公開

はじめに

Next.js(App Router)で、「一覧 → 詳細 → 戻る」の操作をした時に
元のカードの位置まで自動スクロールする機能を実装しました。

やりたいこと自体はシンプルなんですが、実装してみたらまったく動いてくれない。
原因を追っていくと、複数のuseEffectが同じstateを奪い合って壊し合うという
構造的な問題に行き当たりました。

React公式ドキュメントでもuseEffectの連鎖はアンチパターンとされていますが、
この記事ではなぜそう言われるのかを身をもって体験した過程を書きます。

やりたかったこと

  • 一覧画面(カード型グリッド表示 + ページネーション)から詳細画面に遷移
  • 詳細画面の「戻る」ボタンで一覧に戻った時に、同じページの同じカードまで自動スクロール

イメージとしてはこんな流れです。


ページ番号(3ページ目)とスクロール位置(カードC)の両方を復元する

実装アプローチ

今回の一覧画面はページネーション付きなので、
ブラウザのスクロール復元だけでは足りません。
ページ番号とスクロール位置の両方を復元する必要がありました。

そこで、sessionStorage + URLパラメータを組み合わせて
明示的に復元するアプローチにしました。

  1. 一覧 → 詳細: sessionStorage に現在のページ番号を保存
  2. 詳細 → 一覧: /list?page={page}&scrollTo={itemId} に遷移
  3. 一覧: URLパラメータからページ番号を復元し、データ読み込み後に対象カードへスクロール
// 一覧ページ(簡略化)
const searchParams = useSearchParams();
const scrollToId = searchParams.get("scrollTo");
const initialPageParam = searchParams.get("page");

const [currentPage, setCurrentPage] = useState(() => {
  if (initialPageParam) {
    const parsed = parseInt(initialPageParam, 10);
    return isNaN(parsed) || parsed < 1 ? 1 : parsed;
  }
  return 1;
});
// 詳細ページの「戻る」ボタン
<button onClick={() => {
  const page = sessionStorage.getItem("listPage") || "1";
  sessionStorage.removeItem("listPage");
  router.push(`/list?page=${page}&scrollTo=${id}`);
}}>
  戻る
</button>

そして、これを実際に動かすとまったくスクロールしない

この一覧コンポーネントは、

スクロール復元以外にもフィルタ条件(appliedFilters)の適用、
ページネーション(totalCount / pagination)の管理、
複数データソースのローディング制御などの処理がありました。

原因を調べていくと、currentPage を操作するuseEffectが複数あり、
それらが互いに干渉していることがわかりました。

ここからは、具体的にどう壊れていたかを順に見ていきます。


問題1: データ未取得時のページ番号クランプ

症状

3ページ目から詳細に遷移 → 戻る → なぜか1ページ目が表示される。
つまり、ページ番号が復元されない。

原因

ページ番号が totalCount に基づいてクランプ(上限を制限)されるuseEffectがいました。

useEffect(() => {
  setCurrentPage(prev => {
    const maxPage = Math.max(1, Math.ceil(totalCount / pageSize));
    return prev > maxPage ? maxPage : prev;
  });
}, [totalCount, pageSize]);

これ自体は「存在しないページを表示しない」ためのロジックです。

ただし、問題はコンポーネントのマウント直後でした。
この時点では pagination がまだ null で、totalCount は 0。
すると maxPage は 1 になって、
せっかく復元した currentPage = 31 にクランプされてしまいます。

currentPage を操作するeffectが他にもあるので、
「URLから3を復元 → 別のeffectが1に上書き」という意図しない上書き
起きていたわけです。

修正

useEffect(() => {
  if (!pagination) return; // データ未取得時はスキップ
  setCurrentPage(prev => {
    const maxPage = Math.max(1, Math.ceil(totalCount / pageSize));
    return prev > maxPage ? maxPage : prev;
  });
}, [totalCount, pageSize, pagination]);

教訓

「まだ取得していない」と「本当に0」は違う。

非同期データが来る前にeffectが走ると、
「まだ取得していない」が「本当に0」として扱われてしまう。

基礎的なことかもしれませんが、、、
nullチェックで未取得を判定できるようにしておくことの重要性を改めて感じました。


問題2: スクロール試行の早すぎるフラグ管理

症状

ページ番号はきちんと復元されたのに、スクロールしない。

原因

この問題は、問題1のクランプeffectに加えて、
フィルタ変更時にページを1にリセットするeffect([appliedFilters] を依存配列に持つ)も関係していました。

これらのeffectがマウント直後にcurrentPage1に戻してしまい、
それをトリガーにpage=1のデータ取得が走っていました。

ログを見るとこんな感じ:

GET /api/items?page=1  ← リセット/クランプで一時的にpage=1になった分
GET /api/items?page=3  ← 復元されたpage=3

page=1のレスポンスが先に返ってくると、
itemsが更新されてスクロールのuseEffectが発火します。

対象アイテムはpage=1には存在しないのに、
hasScrolledReftrueにしてしまうのが問題でした。

// NG: 要素が見つからなくてもフラグを立ててしまう
useEffect(() => {
  if (scrollToId && !allLoading && items.length > 0 && !hasScrolledRef.current) {
    hasScrolledRef.current = true; // ← ここ
    const element = document.getElementById(`item-${scrollToId}`);
    if (element) {
      element.scrollIntoView({ behavior: "smooth" });
    }
  }
}, [scrollToId, allLoading, items]);

あとからpage=3のデータが来ても、hasScrolledRef.current はもうtrueなので、
スクロールは実行されません。

別のeffectが currentPage を一時的に書き換えなければ、
page=1のリクエスト自体が発生せず、この問題も起きませんでした。

修正

// OK: 要素が見つかった場合のみフラグを立てる
useEffect(() => {
  if (scrollToId && !allLoading && items.length > 0 && !hasScrolledRef.current) {
    const element = document.getElementById(`item-${scrollToId}`);
    if (element) {
      hasScrolledRef.current = true; // ← 成功時のみ
      element.scrollIntoView({ behavior: "smooth", block: "center" });
    }
  }
}, [scrollToId, allLoading, items]);

教訓

「試行した」と「成功した」は別物。 フラグは成功した時にだけ立てた方が安全そうです。


問題3: React Strict Modeによるrefガードの破壊

症状

開発環境でだけ、ページ番号が復元されない。本番では動く。

原因

フィルタ変更時にページを1にリセットするuseEffectがありました。
ただ、初回マウント時(=ページ復元時)にはこのリセットをスキップしたいので、
refでガードしようとしたわけです。

const isRestoringRef = useRef(!!initialPageParam);

useEffect(() => {
  if (isRestoringRef.current) {
    isRestoringRef.current = false;
    return; // 初回はスキップ
  }
  setCurrentPage(1);
}, [appliedFilters]);

ですが、、React Strict Modeは開発環境でeffectを2回実行します

  1. 1回目: isRestoringRef.currenttruefalse に変更 → return(スキップ成功)
  2. 2回目: isRestoringRef.current はもう falsesetCurrentPage(1) が実行されてしまう

refの値はStrict Modeの再実行間でリセットされないので、
2回目の実行であっさりガードが突破されます。

そもそもこのrefガードが必要になったのは、
currentPage を操作するeffectが複数あり、
それらの実行順序を手動で制御しようとしたからでした。

useEffectの連鎖が生んだ問題を、別のuseEffectで抑え込もうとして、
さらに複雑になっていた感がありました。

修正

refベースのガードはやめて、useEffectの実行順序を利用するようにしました。

// ページリセット(フィルタ変更時)
useEffect(() => {
  setCurrentPage(1);
}, [appliedFilters]);

// ページ復元(リセットの後に定義し、上書きする)
useEffect(() => {
  if (initialPageParam) {
    const parsed = parseInt(initialPageParam, 10);
    if (!isNaN(parsed) && parsed >= 1) {
      setCurrentPage(parsed);
    }
  }
}, [initialPageParam]);

useEffectはコンポーネント内の定義順に実行されます。
React 18では、useEffect内の複数の setState もバッチされ、
1回のレンダーにまとめられます。

同じstateに対して複数回 setState が呼ばれた場合、
最後の呼び出しが最終的な値になります。つまり:

  1. リセットeffect: setCurrentPage(1)
  2. 復元effect: setCurrentPage(3)
  3. 両方のsetStateがキューに入り、最終的に currentPage3 で次のレンダーが走る

この方法ならStrict Modeで2回実行されても、毎回同じ順序で同じ結果になります。
refみたいな「実行のたびに変化する状態」に依存しないのがポイントです。

教訓

React Strict Modeでrefを使ったeffectガードは壊れることがある。
setState の順序とバッチングを利用して、宣言的に解決する方が堅牢だと感じました。


最終的な実装

// 一覧ページ(関連部分のみ)

const searchParams = useSearchParams();
const scrollToId = searchParams.get("scrollTo");
const initialPageParam = searchParams.get("page");
const hasScrolledRef = useRef(false);

// ページ番号の初期値をURLパラメータから設定
const [currentPage, setCurrentPage] = useState(() => {
  if (initialPageParam) {
    const parsed = parseInt(initialPageParam, 10);
    return isNaN(parsed) || parsed < 1 ? 1 : parsed;
  }
  return 1;
});

// フィルタ変更時のページリセット
useEffect(() => {
  setCurrentPage(1);
}, [appliedFilters]);

// URLパラメータからページ番号を復元(リセットを上書き)
useEffect(() => {
  if (initialPageParam) {
    const parsed = parseInt(initialPageParam, 10);
    if (!isNaN(parsed) && parsed >= 1) {
      setCurrentPage(parsed);
    }
  }
}, [initialPageParam]);

// データ未取得時はクランプしない
useEffect(() => {
  if (!pagination) return;
  setCurrentPage(prev => {
    const maxPage = Math.max(1, Math.ceil(totalCount / pageSize));
    return prev > maxPage ? maxPage : prev;
  });
}, [totalCount, pageSize, pagination]);

// 自動スクロール(すべてのローディング完了後に実行)
const allLoading = itemsLoading || categoriesLoading || configLoading || userLoading;
useEffect(() => {
  if (scrollToId && !allLoading && items.length > 0 && !hasScrolledRef.current) {
    const element = document.getElementById(`item-${scrollToId}`);
    if (element) {
      hasScrolledRef.current = true;
      let timerId: ReturnType<typeof setTimeout>;
      const rafId = requestAnimationFrame(() => {
        element.scrollIntoView({ behavior: "smooth", block: "center" });
        // 戻り先のカードを一時的にハイライト
        element.classList.add("ring-2", "ring-primary");
        timerId = setTimeout(() => {
          element.classList.remove("ring-2", "ring-primary");
        }, 2000);
        // router.replaceだと再レンダーが走るので、history APIで静かにURLを更新
        const url = new URL(window.location.href);
        url.searchParams.delete("scrollTo");
        url.searchParams.delete("page");
        window.history.replaceState({}, "", url.toString());
      });
      return () => {
        cancelAnimationFrame(rafId);
        if (timerId) clearTimeout(timerId);
      };
    }
  }
}, [scrollToId, allLoading, items]);

最終実装の補足

hasScrolledRef は Strict Mode で壊れないのか?

問題3で「refベースのeffectガードは壊れることがある」と書きましたが、
このスクロール用の hasScrolledRef はおそらく問題ありません。

問題3の isRestoringRef は「1回目の実行でfalseに書き換わり、
2回目で条件が変わる」という不可逆な状態変化が問題でした。

一方 hasScrolledRef は、仮にStrict Modeで2回実行されて1回目でtrueになり2回目がスキップされたとしても、スクロールが1回実行されるだけで実害はありません。

「ガードが突破されて意図しない処理が走る」のが問題であって、
「2回目がスキップされる」のは問題にならないわけです。

requestAnimationFrame を挟んでいる理由

useEffect内でDOMを操作するとき、
ブラウザのレイアウト計算がまだ終わっていないことがあります。

requestAnimationFrame で次の描画フレームまで待つことで、
DOMの位置計算が確定してからスクロールを実行できます。

これがないと、スクロール位置が微妙にずれることがありました。

なお、cleanup関数で cancelAnimationFrameclearTimeout を呼んでいるのは、effectの再実行やアンマウント時に不要なコールバックが走るのを防ぐためです。


まとめ:なぜuseEffectを連鎖させてはいけないのか

今回ぶつかった3つの問題を振り返ります。

問題 原因 対策
ページ番号クランプ データ未取得時のtotalCount=0で復元値が上書き pagination !== null で未取得を判定
早すぎるフラグ管理 要素未発見でもスクロール試行済みとマーク 成功時のみフラグを立てる
Strict Mode refガードがeffectの2回実行で突破される effectの定義順序とバッチングで宣言的に解決

3つの問題は互いに連鎖しています。クランプが復元を壊し(問題1)、リセットeffectが不要なリクエストを生み(問題3→問題2)、早すぎるフラグ管理がとどめを刺す(問題2)。一つのeffectのバグが、別のeffectを巻き込んで連鎖的に壊れていく。これが「useEffectの連鎖」の難しさでした。

個々の修正は数行で済みましたが、
特にeffect同士の連鎖やStrict Modeが絡む問題は原因の切り分けに苦労しました。

根本的にはどうしたかったのか

今回の記事では「useEffectをどう正しく組み合わせるか」に焦点を当てて修正しましたが、一歩引いて見ると、複数のuseEffectが currentPage という同じstateを奪い合っている構造自体が危うかったとも言えます。

理想的には、ページ状態の管理元をURLに一本化して single source of truth にしたり、useReducer やカスタムフックに集約することで、effect同士が干渉するリスクを構造的に減らせるはずです。

React公式ドキュメントの「You Might Not Need an Effect」の中に、
まさに今回のような問題を扱った Chains of computations というセクションがあります。

あるstateの変更をトリガーにして別のstateを変更するuseEffectを連鎖させるパターンが、不要な再レンダーや複雑なデータフローを生むことが解説されていて、今回の問題と根が同じです。

ただ、既存のコードベースをいきなり理想形にリファクタリングするのは現実的ではないことも多いです。

この記事は「今あるコードの中でどう直したか」という過程の記録として書きました。もし同じように複数のuseEffectが絡み合って苦しんでいる方がいたら、まずは個別の修正で動くようにしつつ、次の機会にeffectの数自体を減らす設計を検討してみてください。

Discussion