Next.js の全画面ローダーで旧 UI のちらつきを防ぐ実装
shared-shopping-app は、Next.js 15 と React 19、TypeScript で構成された共有買い物リストアプリである。2026年07月06日から2026年07月12日のワークログを見直すと、この週で最も再利用しやすい知見は、設定画面の初回ロード中に旧ヘッダーや本文コンテナが一瞬見えてしまう問題を、全画面ローダーと CSS 制御、Playwright の受け入れテストで潰した実装だった。
画面のロード中にローダーは出ているのに、背後で古い UI がちらつく不具合は、見た目の小さな違和感で終わらない。PC レイアウト最適化後は特に、main の余白や topbar が残っているだけで「前の画面が一瞬露出した」ように見え、アプリ全体の完成度を下げる。この記事では、共通ローダーを使う Next.js アプリで、このちらつきをどう抑えるかを整理する。
この記事で解決すること
- 全画面ローダーを出しているのに、旧 UI が背後で一瞬見える問題をどう止めるか分からない。
- React コンポーネントと CSS のどちらに責務を置くべきか判断したい。
- UI 修正だけで終わらせず、E2E、build、配備導線まで含めて変更の境界を整理したい。
実装の要点
この週次記事で扱う変更は、shared-shopping-app の設定画面ロード中表示の改善に絞っている。具体的には、設定画面コンポーネント components/list-settings-client.tsx でロード中専用の暫定ヘッダーと簡易カードを返していた構成をやめ、既存の共通部品 FullScreenAppLoader に統一した。
同時に app/globals.css へ、.app-loading-screen が存在する間は .page-shell > .topbar を隠し、main の幅・余白・padding をリセットするルールを追加した。これでローダーを表示している間に、PC 向けレイアウトで広がった本文領域や旧ヘッダーが背後に残らないようにした。
検証面では tests/e2e/guest-list.spec.ts を更新し、ローダー表示中に .topbar が hidden であること、main 配下に .app-loading-screen 以外の旧 UI がないことを Playwright で確認する受け入れ条件を追加した。
得られた知見
成果は、ローディング中の体験を「ローダーを表示する」から「ローダー以外を見せない」へ定義し直せたことにある。設定画面だけ別の待機 UI を持つ状態をやめ、アプリ共通の待機表示に統一したことで、ページごとに異なる仮表示が混ざる状態を解消できた。
コード上の成果として確認できたのは次の3点である。
- 設定画面のロード中表示が
FullScreenAppLoader1つに集約された。 - CSS がページシェル全体を抑制するようになり、ヘッダーと本文コンテナの露出経路が塞がれた。
- Playwright が「ローダーが見える」だけでなく「旧 UI が残っていない」ことまで検証するようになった。
実際に根拠として確認できた差分は、commit d435e20 で app/globals.css、components/list-settings-client.tsx、components/nav.tsx、tests/e2e/guest-list.spec.ts の 4 ファイルに限定されていた点である。変更が UI と E2E に閉じていたため、今回の記事も API や DB の話を混ぜずに説明できる。
一方で、ワークログから確認できる範囲は commit d435e20 の差分とテスト更新までである。npm run build、npm run test:e2e、npm run cf:build、npm run cf:deploy はリポジトリ側に用意された build / test / deployment / release 導線として確認できるが、この日の source material から実行結果までは確認できなかった。そのため、この変更は「配備可能な構成へ寄せた UI 改修」であり、「本番反映まで完了した変更」とは書かないのが正確である。
実装プロセス
最初に切り分けるべきだったのは、問題がローダー部品の見た目ではなく、ページシェル全体の責務にあるという点だった。設定画面ではロード中に専用の app bar と status card を描いていたため、React ツリー上で旧ページ構造が一度出てから、新しい待機表示へ置き換わる余地が残っていた。
そこで実装は 2 段階に分けた。1 段階目は components/list-settings-client.tsx で isLoading 中の返り値を FullScreenAppLoader だけにし、不完全な設定画面構造を出さないようにすること。2 段階目は app/globals.css に body:has(.app-loading-screen) 条件のルールを追加し、ローダーが存在する間は .topbar と main の通常レイアウトを抑制することだった。
さらに components/nav.tsx を fragment 構造へ調整し、ログアウト時ローダーのような全画面オーバーレイもヘッダー外に重ねやすい DOM に整えた。最後に Playwright で、初回表示時に .topbar が hidden であり、.category-card-skeleton や古い本文パネルが出ていないことを受け入れ条件に固定した。見た目の違和感を DOM 条件へ落とし込んだことで、将来の CSS 変更でも同じ不具合を検出しやすくなった。
利用した技術
- アプリ本体は Next.js 15 の App Router、React 19、TypeScript で構成されている。
- 共通ローディング UI は
components/app-loader.tsxのFullScreenAppLoaderを使い、設定画面専用の暫定 UI を新設しない方針を取った。 - 画面制御は
app/globals.cssに集約し、body:has(.app-loading-screen)を使ってページシェル全体の chrome を抑制した。 - 設定画面の実装は
components/list-settings-client.tsx、共通ヘッダーはcomponents/nav.tsxが担う。 - 受け入れテストは Playwright の
tests/e2e/guest-list.spec.tsで、ローダー表示中の DOM 条件を確認する。 - リポジトリには
npm run test:e2eが E2E、npm run buildが Next.js build、npm run cf:buildとnpm run cf:deployが Cloudflare 向け deployment / release 導線として定義されている。
今回の変更そのものは UI 実装と E2E テスト更新に閉じており、build 設定や deployment スクリプトの定義自体は変更していない。つまり、ここで使った技術の中心は「表示責務の整理」と「回帰確認の強化」である。
アーキテクチャ
この変更の要点は、ローディング状態の責務を 3 層に分けたことにある。待機表示そのものは FullScreenAppLoader が担い、ページ固有のロード判断は ListSettingsClient が担い、待機中に通常 chrome を引っ込める責務は CSS が担う。これにより、ローダーの文言や見た目を共通化しつつ、ページシェルの露出抑制を個別ページへ書き散らさずに済む。
flowchart TD
A["ListSettingsClient<br/>isLoading を判定"] --> B["FullScreenAppLoader を表示"]
B --> C["body:has(.app-loading-screen)"]
C --> D["topbar を非表示"]
C --> E["main の幅・余白をリセット"]
F["Playwright E2E"] --> D
F --> Eここで重要なのは、React 側だけで解決しようとしなかった点である。ローダーが出ていても、共通ナビゲーションや main のレイアウト制約が別レイヤーで残っていれば、見た目としては不完全な画面が露出する。だからこそ、コンポーネント、DOM 構造、CSS、E2E がそれぞれ違う責務を持つ構成にした判断が効いている。
選定理由
設定画面専用の loading app bar を維持せず共通ローダーへ寄せたのは、待機中 UI をページごとに増やすと、表示差異と回帰確認のコストが増えるからである。共通ローダー 1 つに寄せれば、ローディング体験の変更点を一か所に集約できる。
topbar や main を React の条件分岐で個別に制御しなかったのは、ページシェル全体を抑制したい要件に対して、CSS の方が責務の置き場所として素直だったからである。body:has(.app-loading-screen) はやや強いセレクタだが、「全画面ローダーが存在する間は通常 chrome を出さない」という契約を明快に表現できる。
Playwright を使って hidden 判定と DOM 件数確認を入れたのは、スクリーンショットの印象比較だけでは「旧 UI が残っていない」ことを安定して表現しづらいためである。今回は見た目の調整ではなく、受け入れ条件の明文化が価値だった。
また、この改善を UI 層と E2E 層に閉じたのは妥当だった。問題の原因は API やデータストアではなく、初回描画とページシェルの見せ方にあったからである。影響範囲を最小化しながら修正できる選択だった。
採用しなかった案もある。ひとつは設定画面専用の loading header を残したまま見た目だけ調整する案で、これはページごとの差異と保守コストを増やすため見送った。もうひとつは nav や main を React 条件分岐で個別に隠す案で、共通 chrome の抑制責務が複数コンポーネントへ分散するため採らなかった。
導入手順
同じ問題を別の Next.js アプリへ導入するなら、次の順で進めると再現しやすい。
- ロード中に表示している専用ヘッダーや仮カードがないか確認し、ページ固有の暫定 UI を洗い出す。
isLoadingなどの待機判定で、ページ固有の暫定 UI を返す代わりに共通の全画面ローダーだけを返す。.app-loading-screenのようなマーカーを使い、CSS 側でtopbar、main、余白、幅制約などの通常 chrome を抑制する。- 全画面ローダーが共通ヘッダーやログアウト処理と競合しないよう、必要ならヘッダー側の DOM 構造を調整する。
- Playwright などの E2E で「ローダーが表示される」だけでなく、「旧 UI が残っていない」ことを hidden 判定や DOM 件数で固定する。
- 実装後は少なくとも
npm run test:e2eとnpm run buildを回して、UI 回帰と build 崩れを確認する。 - Cloudflare Workers や OpenNext で配備する構成なら、必要に応じて
npm run cf:build、npm run cf:deployを release 手順へつなぐ。ただし今回の source material では、この配備実行までは確認できていない。
この事例で根拠確認に使えたコマンドは次のとおりである。
git -C ~/git/shared-shopping-app show --stat --summary --format=fuller d435e20
git -C ~/git/shared-shopping-app show --format=medium --unified=80 d435e20 -- app/globals.css components/list-settings-client.tsx components/nav.tsx tests/e2e/guest-list.spec.tsテストの観点では、tests/e2e/guest-list.spec.ts に await expect(page.locator(".topbar")).toBeHidden() と await expect(page.locator("main > :not(.app-loading-screen)")).toHaveCount(0) が追加されており、ちらつき対策の受け入れ条件がコードとして固定されている。
導入時の課題
一番の課題は、全画面ローダーを出していても、背後の topbar や main が残るだけで不具合に見える点だった。特に PC レイアウト最適化後は、余白や幅制約が強く見えるため、ローダーの背後に旧 UI が残ると違和感が増幅される。
次の課題は、共通ヘッダーがアプリ全体で使われていることだった。設定画面だけ直したくても、ローダーとの重なり方は nav 側の DOM 構造に依存する。そのため、ページ単体の条件分岐だけでは足りず、共通部品との接続面まで見直す必要があった。
最後に、見た目の問題はテストへ落とし込みにくい。今回は .topbar の hidden 判定と main > :not(.app-loading-screen) の件数確認へ変換したが、このように DOM 条件へ翻訳しないと、将来の変更で同じちらつきが戻りやすい。
次に検討した方が良いこと
- 設定画面以外でも
FullScreenAppLoaderを使う箇所を洗い出し、同じ CSS 契約を横展開する。 npm run test:e2eを継続的に回し、ログイン画面、公開リンク画面、ログアウト処理でも旧 chrome が露出しないか確認する。npm run buildとnpm run cf:buildを CI へ載せ、UI 修正が OpenNext / Cloudflare 向け build を壊していないか自動検知する。body:has(...)の依存を続けるなら、サポートブラウザ方針とフォールバック方針を明文化する。- 本番配備前には preview 環境で初回描画を実機確認し、E2E では拾いきれない体感差分を補う。