はじめに
スクロールに合わせて要素をふわっと出す、あのよくある演出。
Next.js の App Router でそれを入れたら、トップページのFAQが「他のページから戻ってきた時だけ真っ白のまま出てこない」という状態になりました。
でもリロード(F5)すると出る。
最初はFAQコンポーネント側のバグだと思っていたんですが、真犯人は全然別のところにいました。
この記事は、その切り分けと直し方の記録です。
こんな人に向けて書いています
- Next.js の App Router で、スクロール連動のフェードイン演出を入れている人
- 「リロードすると出るのに、ページ遷移で来ると出ない」要素に遭遇している人
-
useEffectの依存配列を空([])にした処理を、レイアウトに置いている人
この記事でわかること
- 「遷移で来た時だけ表示されない」症状を、どう切り分けて原因まで辿ったか
- App Router のレイアウトが遷移で再マウントされないことが、なぜ表示バグになるのか
-
usePathname()を依存配列に入れる直し方と、セレクタで二度付けを避ける理由
前提
- Next.js の App Router(
app/layout.tsxのあるプロジェクト) - React + TypeScript
- CSS と
IntersectionObserverでフェードインを実装している構成
前提知識:用語の整理
先に用語だけ揃えておきます。
- クライアント遷移:今回は、他ページからヘッダーのリンクでトップへ戻る遷移で発生しました
- レイアウトの再マウント:App Router のレイアウトは、同じレイアウトを共有するページ間のクライアント遷移では再マウントされません
-
useEffectの依存配列:第2引数の配列。ここを空([])にすると、その処理はマウント時の一度きりしか走りません -
IntersectionObserver:要素が画面に入ったかどうかを監視するもの。入ったタイミングでコールバックが呼ばれます
何を作っていたか
やりたかったのはシンプルで、.fade-in というクラスを付けた要素を、画面に入ったタイミングでふわっと表示するやつです。
CSSはこんな感じ。
最初は透明にしておいて、is-visible が付いたらアニメーションで出す。
.fade-in { opacity: 0; }
.fade-in.is-visible {
animation: fadeSlideUp 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
で、is-visible を付ける係を1個だけ作って、ルートレイアウト(app/layout.tsx)に置いていました。
全ページ共通で効かせたかったので、グローバルに1個。
これが後で効いてくる伏線です。
"use client";
import { useEffect } from "react";
export default function ScrollObserver() {
useEffect(() => {
const targets = document.querySelectorAll<HTMLElement>(".fade-in");
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.12 }
);
targets.forEach((el) => {
// 初回ロード時点で画面内にある要素は即表示(上が真っ白になる対策)
if (el.getBoundingClientRect().top < window.innerHeight) {
el.classList.add("is-visible");
} else {
observer.observe(el);
}
});
return () => observer.disconnect();
}, []); // ← ここが今回の主犯
return null;
}
useEffect の依存配列が空。
マウント時に一度だけ .fade-in を集めて監視する。
ぱっと見、何も悪くないですよね。
切り分け:DOMにはあるのに、表示する係が仕事をしていない
報告されたのは「トップのFAQが更新しないと出てこない」。
最初にやったのは、FAQコンポーネントを疑うことでした。
中で useState 使ってるし、条件分岐で消えてるのかな、とか。
でも見ても普通で、FAQの項目自体はちゃんとDOMに存在してる。
ブラウザのコンソールで数えたら、<button> が6個ある。
中身はある。
ただ opacity: 0 のまま is-visible が付いていない。
要素はあるのに、表示する係が仕事をしていない。
切り分けを進めると、2つのことが分かってきました。
- ハードロード(URL直打ちやF5)でトップを開くと、スクロールすればFAQはちゃんと出る
- 他のページから、ヘッダーのリンクでトップに戻ってくると、FAQが出ない
つまり「最初から壊れている」んじゃなくて、「クライアント遷移で来た時だけ壊れる」。
もう一つ手掛かりがあって、トップの他のセクション(サービスとか実績とか)はちゃんとフェードインするんです。
FAQだけ出ない。
この差が答えでした。
原因:レイアウトは遷移で再マウントされない
App Router のレイアウトは、同じレイアウトを共有するページ間のクライアント遷移では再マウントされません。
これは便利な仕様で、ヘッダーやフッターが遷移のたびにチカチカしないのはこのおかげです。
でも今回はこれが裏目に出ました。
ScrollObserver はルートレイアウトに置いてあります。
だから useEffect(() => {...}, []) はアプリで最初にレイアウトがマウントされた一度きりしか走りません。
その時点の .fade-in を集めて監視して、終わり。
その後クライアント遷移で別のページに行って、また戻ってくると、ページの中身は新しく描画されます。
トップの .fade-in たちも新しい要素です。
でも ScrollObserver の useEffect はもう二度と走らないので、この新しい要素は誰にも監視されないまま opacity: 0 で放置される。
だから真っ白。
F5で直るのは、ハードリロードでレイアウトごと作り直されて、useEffect がもう一回走るからです。
「更新しないと出てこない」の正体はこれでした。
なぜ他のセクションは無事だったのか
トップの他の部分は、.fade-in の生クラスじゃなくて、自前のフェードイン用コンポーネントで包んでいました。
export default function FadeIn({ children, ... }: Props) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
// 画面内なら即表示、そうでなければ自前のObserverで監視
// ...
}, [delay]);
return <div ref={ref} className={`fade-in ${className}`}>{children}</div>;
}
このコンポーネントはインスタンスごとに自分の useEffect と Observer を持っている。
だから遷移で新しくマウントされるたびに、ちゃんと自分で自分を表示しにいく。
自己修復するんですね。
FAQだけが、グローバルな ScrollObserver に表示を委ねる生の .fade-in を使っていた。
だからFAQだけが死角に落ちた。
犯人は「1個のグローバルObserver」と「クライアント遷移」の相性の悪さでした。
直し方:usePathname() を依存配列に入れる
やることは単純で、usePathname() を依存配列に入れて、遷移のたびに走らせ直すだけです。
"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
export default function ScrollObserver() {
const pathname = usePathname();
useEffect(() => {
// まだ表示されていないものだけ拾う(遷移時の二度付けを避ける)
const targets = document.querySelectorAll<HTMLElement>(".fade-in:not(.is-visible)");
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.12 }
);
targets.forEach((el) => {
if (el.getBoundingClientRect().top < window.innerHeight) {
el.classList.add("is-visible");
} else {
observer.observe(el);
}
});
return () => observer.disconnect();
}, [pathname]); // ← 遷移のたびに走らせ直す
return null;
}
ポイントは2つあります。
1. pathname を依存に入れる。
usePathname() はクライアント遷移で値が変わるので、useEffect がクリーンアップ→再実行されます。
遷移先の新しい .fade-in をちゃんと拾い直せる。
2. セレクタを .fade-in:not(.is-visible) にする。
すでに表示済みのものは触らないようにしました。
これがないと、戻ってきた時に一度出したものまで拾い直してしまう。
地味だけど大事です。
これでFAQも含めて全ページで直りました。
いつ起こるか・何が厄介か
いつ起こるかを一文で言うと、ページごとに新しく描画される要素を、レイアウト側の「一度きりしか走らない副作用」が処理しようとしているときです。
今回起きたのは、レイアウト上の一度きりの副作用で .fade-in を集めていたケースでした。
「アプリ起動時の一度きりでいいのか、遷移のたびに走ってほしいのか」の判断が、依存配列に反映されていなかった、ということですね。
※これ以外にどんなケースがあるかは、今回の事例だけでは断定できません。
困りポイントはこのあたりです。
-
表面上は壊れて見えない。 DOMには要素があり、
opacity: 0のままでis-visibleが付いていない、という状態だけが残る - リロードすると直る。 ハードロードでは出る/他ページから戻ると出ないため、条件の違いを切り分ける必要があった
- 一部だけ壊れるので気づきにくい。 今回は同じページの他のセクションは正常に動いていた
- 見た目が「空白」になる。 重要なコンテンツが消えると事故になる
この件から持ち帰ったこと
今回のミスを一言でいうと、「表示されるかどうか」を、一度しか走らないグローバルな副作用に預けてしまったことでした。
App Routerだと「レイアウトは遷移で再マウントされない」が効いてくる場面がちょいちょいあります。
useEffect(() => {...}, []) をレイアウト側に置くとき、それが「本当にアプリ起動時の一度きりでいいのか」「遷移のたびに走ってほしくないか」を意識するだけで、この手の罠はだいぶ避けられるはず。
ページ単位でやり直したいものは usePathname() を依存に入れる、が定番の対処です。
あと、CSSで初期状態を opacity: 0 にする演出は気持ちいいんですが、**「JSが期待通り動かなかった時にコンテンツが永久に消える」**という副作用とセットです。
今回みたいに大事な情報(FAQ)が消えると普通に事故なので、重要なコンテンツは初期非表示にしない、もしくは表示保証をもっと堅い仕組みに寄せる、というのも一つの判断かなと思ってます。
切り分けの決め手は、結局「ハードロードでは出る/遷移だと出ない」という観察でした。
再現条件が言葉にできると、原因まで一気に近づきます。
同じ「更新しないと出てこない」で悩んでる人の役に立てば。
作ってるもの・受託の相談はこちらから → https://miyoki-labs.com