コードレビューで見つかった問題を、勉強のためにまとめたものです(修正済み)。
この記事でわかること
- Next.js の SSR と CSR が、ブラウザに画面を出すまでに何をしているか
useEffectの中で判定すると、なぜ「一瞬チラつく」現象が起きるのか- どう直したか
- このパターンが Core Web Vitals(SEO 指標)にどう効いてくるか
何が起きていたか
LP のトップ(/)を開くと、画面右上のナビにある「APP」というダウンロードメニューのバッジが、一瞬だけ表示されてから消える現象が起きていた。
挙動の意図はこう:
- LP のヒーローセクションに大きな「App Store ダウンロード」ボタンがある
- なのでナビ右上の小さい「APP」バッジは、ヒーローが見えている間は隠したい(重複するから)
- スクロールしてヒーローが画面外に出たら、ナビ右上の「APP」を出す
実装は IntersectionObserver で hero-cta 要素を監視して、見えていれば隠す方針。
それ自体は妥当なんだけど、初回ロード時に APP が一瞬出て、すぐ消える。デザインとしてめっちゃ気になる。
Next.js の画面表示は2段階(SSR → Hydration)
ここから前提。Next.js (App Router) でページをブラウザに出すとき、判定の材料は置き場所が違います。
React の実行順番(ここが本題)
コンポーネントが描画されるときの内部の順番は、React の設計上の決まりで変えられません。
なぜそうなっているかというと、useEffect の中身は 副作用 と呼ばれるもの(API fetch、IntersectionObserver の設定、event listener の登録、タイマー仕掛け、外部ライブラリの初期化など)が想定されていて、これらは時間がかかる重い処理になりがちです。
画面表示は最速で、副作用は後回し
これが React の思想で、そのために「描画 → useEffect」の順番が固定されています。
つまり「描画する前に何かを判定したい」場合は、useEffect ではなく useState の初期値で決めるしかありません。
| 何で判定するか | タイミング | 描画前に間に合うか |
|---|---|---|
useState の初期値 |
コンポーネント関数の最初 | ✅ 間に合う |
useEffect の中 |
描画の後 | ❌ 間に合わない |
修正前のコード
該当の AppDropdown コンポーネントは、こう書かれていた(抜粋)。
function AppDropdown() {
const [isOpen, setIsOpen] = useState(false);
// SSR では DOM が無いので true 初期値にし、
// effect で hero-cta を見つけたら隠す方向で同期する。
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const heroCta = document.getElementById('hero-cta');
if (!heroCta) return;
const observer = new IntersectionObserver(
([entry]) => {
setIsVisible(!entry.isIntersecting);
},
{ threshold: 0 }
);
observer.observe(heroCta);
return () => observer.disconnect();
}, []);
// ...
}コメントの主張は「サーバには document が無いので、IntersectionObserver で hero-cta が見えてるかどうかは判定できない。だから初期は true にして見せておき、ブラウザでマウントしたら effect で隠す方向で同期する」というもの。
これが今回のチラつきの原因になっていた。
何が起きていたか(時系列)
修正前の挙動を、置き場所の順で追うとこうである。
見えてから消えるまでの数十〜100ms が「チラつき」の正体である。
修正の発想
問題は、サーバ(SSR)には document が無いので、「hero-cta があるかどうか」は判定できません。
そこで考え方を変える。
hero-cta があるページは、実質トップ(
/)だけだと言っていい
LP のトップページにだけヒーローセクションがあるので、/concept や /creation-notes/* には hero-cta はありません。これは LP の構造上、自分たちでコントロールできる事実。
なら 「pathname が / なら hero-cta があるはず → 初期は隠す」 で済みます。
そして Next.js (App Router) の usePathname() は、サーバでもブラウザでも同じ pathname を返します。リクエストの URL はサーバ側で確定してるので、SSR の段階でも / だと分かります。
修正後のコード
function AppDropdown() {
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
// hero-cta は LP トップ (/) のヒーローにのみ存在する想定。
// / では初期表示時に hero-cta が可視なので false 初期値にしてちらつきを防ぎ、
// それ以外のページでは常時表示として true 初期値にする。
const [isVisible, setIsVisible] = useState(pathname !== '/');
useEffect(() => {
// 同じ IntersectionObserver は残す(スクロールで状態を切り替えるため)
const heroCta = document.getElementById('hero-cta');
if (!heroCta) return;
const observer = new IntersectionObserver(
([entry]) => setIsVisible(!entry.isIntersecting),
{ threshold: 0 }
);
observer.observe(heroCta);
return () => observer.disconnect();
}, []);
// ...
}修正後の置き場所はこうである。
ユーザーに「APP」が一瞬見える瞬間が消えた。
なぜこれを直すのか(知覚品質と Core Web Vitals)
「ナビ右上のバッジが一瞬チラつく」だけだと「気にしなければ気にならない範囲」に見えます。でも LP は第一印象が商売道具なので、初期描画の小さなチラつきも品質に効いてきます。
ここで Google が SEO 評価に使う Core Web Vitals との関係を、正確に切り分けておく。今回のバッジは opacity で見え隠れさせていて、表示/非表示で DOM から出し入れ(レイアウトの箱が増減)しているわけではありません。なので——
| 指標 | 何を測るか | 今回のケースは効くか |
|---|---|---|
| CLS (Cumulative Layout Shift) | 要素の位置がどれだけズレたか | 効かない。opacity の変化は位置を動かさないので CLS には加点されない。もし display:none ⇄ block のように要素を出し入れして周囲が動く実装なら CLS に効く |
| LCP (Largest Contentful Paint) | 主要コンテンツ表示までの時間 | ほぼ効かない。最大要素の描画完了が基準で、その後の opacity トグルは LCP を後ろにずらさない |
つまり今回の修正は「CLS スコアを上げるため」ではなく、知覚品質(チラつきという見た目の安っぽさ)を消すため。
ただし見落とせないのは、「useEffect で描画後に状態を変える」という設計そのものは、実装次第で CLS に化けること。同じパターンを 要素の出し入れ でやっているコンポーネントが10箇所あれば、そちらは CLS に面で効いてきます。今回は opacity 実装だから「CLS ではなく知覚品質の話」と切り分けられる──この切り分けができることが大事。
学びポイント
- React の実行順番は 「描画 → useEffect」 で固定。
useEffectの中身は描画より後にしか走れません - 「描画前に正しい状態を出したい」なら、
useStateの初期値で決め打ちします。useEffectで頑張りません useStateの初期値はサーバとブラウザの両方で評価されるので、両者で同じ値が出るような根拠(usePathname()のような SSR-safe な API)に基づいて決めます- LP は 第一印象が刺さる商売 なので、初期描画のチラつきは商売道具の品質に直結します。フリッカーの修正は「小さい UX バグ」ではなく「LP の信頼感」に効きます
おわりに
useEffect の中で判定して setState で直す、というパターンはありがちだけど、描画後に走るという性質を忘れると、今回のような「一瞬チラつく」現象を引き起こしやすいです。
「これ useEffect でやる必要ある? useState の初期値で決められる?」と一拍考えるクセをつけると、フリッカー系のバグは元から踏まなくなります。
そのとき決め手になるのは、サーバとブラウザの両方で同じ値が出る情報源 を持っているかどうか。Next.js (App Router) の usePathname() のような SSR-safe な API は、その意味で強い味方になります。
この記事は2026年5月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。