紡Book の勉強ノートに戻る
コードレビュー学習2026.05.09

ページを開いた瞬間、メニューが一瞬チラつく — 最初の表示は useState の初期値で決める(Next.js)

Next.jsReactSSRHydrationWeb Vitalsperformance

コードレビューで見つかった問題を、勉強のためにまとめたものです(修正済み)。

この記事でわかること

  • 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) でページをブラウザに出すとき、判定の材料は置き場所が違います。

APP の見え隠れは DB に無い。pathname はリクエスト URL、isVisible は React メモリ、hero-cta はブラウザ DOM。リクエストの URLpathnameサーバでも同じ・保存しない描画前React のメモリisVisibleuseState・保存しない焼き込み最初の HTML画面(JS の前)サーバが組み立てて送るブラウザ DOMhero-ctaサーバには無い描画のあとuseEffect は最初の HTML より後。Hydration(HTML とメモリの結合)が終わってから走る。

React の実行順番(ここが本題)

コンポーネントが描画されるときの内部の順番は、React の設計上の決まりで変えられません。

なぜそうなっているかというと、useEffect の中身は 副作用 と呼ばれるもの(API fetch、IntersectionObserver の設定、event listener の登録、タイマー仕掛け、外部ライブラリの初期化など)が想定されていて、これらは時間がかかる重い処理になりがちです。

画面表示は最速で、副作用は後回し

これが React の思想で、そのために「描画 → useEffect」の順番が固定されています。

つまり「描画する前に何かを判定したい」場合は、useEffect ではなく useState の初期値で決めるしかありません。

何で判定するか タイミング 描画前に間に合うか
useState の初期値 コンポーネント関数の最初 ✅ 間に合う
useEffect の中 描画の後 ❌ 間に合わない

修正前のコード

該当の AppDropdown コンポーネントは、こう書かれていた(抜粋)。

tsx
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 で隠す方向で同期する」というもの。

これが今回のチラつきの原因になっていた。

何が起きていたか(時系列)

修正前の挙動を、置き場所の順で追うとこうである。

修正前。isVisible の true は React メモリにあり、保存しない。最初の HTML に焼き込まれる。サーバのメモリisVisible は trueURL は使っていない最初の HTMLAPP を表示JS の前に見えるブラウザのメモリ同じく true結合は一致ブラウザ DOMhero-cta描画後 false画面に APP が見えてから消えるDOM の hero-cta は描画のあとでしか読めない。保存しない。

見えてから消えるまでの数十〜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 の段階でも / だと分かります。

修正後のコード

tsx
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();
    }, []);
 
    // ...
}

修正後の置き場所はこうである。

修正後。pathname はリクエスト URL。/ なら初期値 false。保存しない。リクエスト URLpathname は /サーバでも分かるサーバのメモリisVisible は false保存しない最初の HTMLAPP は非表示最初から見えないブラウザ DOMhero-cta既に false最初の HTML から APP は無いDOM の判定も false。再描画しない。保存しない。

ユーザーに「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月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。