0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【React】display:noneでページ切り替えたらUXが劇的に改善した

Last updated at Posted at 2026-01-04

はじめに

💡React 19.2以降をお使いの方へ

この記事で紹介している display: none パターンは、
React 19.2の <Activity /> コンポーネントを使って、より簡潔に実装できます。
ただし、この記事の内容も仕組みの理解のためには有効です。

<Activity /> への移行方法と違いについては、続編記事を書きました。


以前、「React Router v7で無限スクロールのスクロール位置とリストデータを復元する方法」という記事で、SPAでブラウザバック時にデータとスクロール位置を復元する方法を紹介しました。

あの方法も効果的でしたが、今回紹介する方法はさらに速いです。

画面切り替え速度の比較

アプローチ 画面切り替え時間 体感
復元パターン(前回の記事)
コンポーネント再マウント
100〜300ms 一瞬のちらつき
保持パターン(今回の記事)
display切り替え
~16ms 瞬時、ネイティブアプリ並み

最大で20倍近く高速化しました。

2つのアプローチの違い

復元パターン(前回の記事)

  • React Routerの<ScrollRestoration />でスクロール位置を復元
  • SWRのキャッシュ機能でデータを復元
  • コンポーネントはアンマウント→再マウントされる
  • 画面切り替え:100〜300ms(再レンダリングが発生)

保持パターン(今回の記事)

  • コンポーネントをアンマウントせず、CSSのdisplay: none/blockで切り替え
  • 状態を復元するのではなく、そもそも失わない
  • 画面切り替え:~16ms(1フレーム、再レンダリング不要)
  • ⚠️ メモリ使用量は増加(3〜5ページ程度なら許容範囲)

「速さ」が最大の利点です。ネイティブアプリのような瞬時の切り替えを実現できます。

特にPWA(Progressive Web App)で効果を発揮します:

  • インストール可能なアプリとしてのネイティブ感
  • オフライン対応時の状態保持
  • タブ切り替えのような瞬時の画面遷移

この記事で紹介する内容

  • display: none/block でページコンポーネントを切り替える実装方法
  • React Routerと組み合わせた状態保持戦略
  • 「復元」アプローチとの比較・使い分け
  • メモリとUXのトレードオフ

2つのアプローチの詳細比較

パフォーマンスの違い(最重要)

画面切り替え速度:

アプローチ 処理時間 速度差
復元パターン 100〜300ms -
保持パターン ~16ms 最大20倍速い

なぜこんなに速いのか?

  • 復元パターン:コンポーネント破棄 → データ復元 → 再レンダリング → スクロール復元
  • 保持パターン:CSSのdisplayプロパティ変更のみ(DOMは維持)

アプローチ1:復元パターン(前回の記事)

コンポーネントはアンマウント→再マウントされるが、状態を復元する:

function App() {
  return (
    <Routes>
      <Route path="/" element={<SearchPage />} />
      <Route path="/mylist" element={<MyListPage />} />
    </Routes>
  )
}

function SearchPage() {
  // 復元ロジック
  const pageSizes = useRef(new Map())
  const currentKey = location.key
  const savedSize = pageSizes.current.get(currentKey) ?? 1

  const { data } = useSWRInfinite(
    (index) => `/api/search?page=${index}&q=${keyword}`,
    fetcher,
    {
      initialSize: savedSize,  // ページ数を復元
      persistSize: true,
      revalidateAll: false
    }
  )
}

メリット:

  • ✅ メモリ効率が良い(不要なコンポーネントはアンマウント)
  • ✅ 多数のページがある大規模サイトに向いている
  • ✅ ページごとのクリーンアップが確実

デメリット:

  • 画面切り替えが遅い(100〜300ms)
  • ❌ 再レンダリングによるちらつき
  • ❌ 復元処理の実装が必要(SWR、Redux、sessionStorageなど)
  • ❌ スクロール位置復元のタイミング制御が難しい

アプローチ2:保持パターン(今回の記事)

コンポーネントをアンマウントせずdisplayで切り替える:

function App() {
  const location = useLocation()
  const isSearchPage = location.pathname === '/'
  const isMyListPage = location.pathname === '/mylist'

  return (
    <>
      {/* 常にマウント、表示/非表示を切り替え */}
      <div className={isSearchPage ? 'block' : 'hidden'}>
        <SearchPage />
      </div>

      <div className={isMyListPage ? 'block' : 'hidden'}>
        <MyListPage />
      </div>
    </>
  )
}

メリット:

  • 圧倒的に速い(~16ms、最大20倍高速)
  • ✅ 再レンダリング完全不要(ちらつきなし)
  • ✅ 状態が自動的に保持される(復元処理不要)
  • ✅ 実装がシンプル
  • ✅ Vue.jsの<keep-alive>に近い直感的な動作

デメリット:

  • ❌ メモリ使用量が増加(全ページが常にマウント)
  • ❌ 初期ロード時間がやや増加
  • ❌ 多数のページには不向き(3〜5ページ程度が限界)

保持パターンの仕組みと実装

なぜ状態が保持されるのか?

Reactでは、コンポーネントがDOMからアンマウント(削除)されると、そのコンポーネントの状態は破棄されます。

// ❌ 通常のルーティング:コンポーネントがアンマウント→マウント
{location.pathname === '/' && <SearchPage />}
{location.pathname === '/mylist' && <MyListPage />}

// ページ遷移時:
// 1. SearchPage がDOMから削除される(アンマウント)
// 2. MyListPage がDOMに追加される(マウント)
// 3. SearchPage の状態(検索結果、スクロール位置)は完全に消える

しかし、CSSのdisplay: noneで隠すだけなら、DOMには残り続けるため、状態は保持されます:

// ✅ display切り替え:コンポーネントはDOMに残り続ける
<div style={{ display: isSearchPage ? 'block' : 'none' }}>
  <SearchPage />
</div>
<div style={{ display: isMyListPage ? 'block' : 'none' }}>
  <MyListPage />
</div>

// ページ遷移時:
// 1. SearchPage は display: none になるだけ(DOMには存在)
// 2. MyListPage は display: block になる
// 3. SearchPage の状態は保持されたまま

完全な実装例(Tailwind CSS)

React Routerと組み合わせた実装方法です:

import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'

function App() {
  return (
    <BrowserRouter>
      <MainApp />
    </BrowserRouter>
  )
}

function MainApp() {
  // React RouterのuseLocationフックで現在のパスを取得
  const location = useLocation()

  // 現在のパスに応じて、どのページを表示するか判定
  const isSearchPage = location.pathname === '/'
  const isMyListPage = location.pathname === '/mylist'
  const isSettingsPage = location.pathname === '/settings'
  const isDetailPage = location.pathname.startsWith('/openchat/')

  return (
    <>
      {/*
        メインページ(Search, MyList, Settings)は常にマウント。
        display: none/block で表示を切り替える。
        これにより、非表示の間も状態は保持される。
      */}
      <div style={{ display: isSearchPage ? 'block' : 'none' }}>
        <SearchPage />
      </div>

      <div style={{ display: isMyListPage ? 'block' : 'none' }}>
        <MyListPage />
      </div>

      <div style={{ display: isSettingsPage ? 'block' : 'none' }}>
        <SettingsPage />
      </div>

      {/*
        詳細ページは条件付きレンダリング(真のオーバーレイ)。
        毎回最新データを取得する必要があるため、アンマウント→マウントさせる。
      */}
      {isDetailPage && <DetailPage />}

      {/*
        React Routerの<Routes>は使わない。
        代わりに、location.pathname で自前で判定する。
        こうすることで、display切り替えを完全に制御できる。
      */}
    </>
  )
}

重要なポイント:

  1. <Routes>を使わない:通常のReact Routerのルーティングは使わず、location.pathnameで自前判定
  2. 常にマウント:3つのメインページは初回レンダリング時に全てマウントされる
  3. display切り替え:ページ遷移時はdisplay: none/blockを切り替えるだけ
  4. オーバーレイは別扱い:詳細ページなど、毎回新しいデータが必要なページは条件付きレンダリング

💡 React 19.2以降なら <Activity /> を使おう

この display: none パターンは、React 19.2の公式API <Activity /> コンポーネントでより簡潔に実装できます。

<Activity /> の主な利点

  • ✅ 副作用(useEffect)が自動制御される ← 最大の違い
  • ✅ コードがより簡潔
  • ✅ 公式APIで将来的な最適化に対応

実装例:

import { Activity } from 'react'

<Activity mode={location.pathname === '/' ? 'visible' : 'hidden'}>
    <SearchPage />
</Activity>

Next.jsでの適用について

Next.jsを使っている場合の注意点:

Next.jsは通常、ファイルベースルーティング(App Router / Pages Router)を使用しますが、この保持パターンはクライアントサイドのみで動作します。

適用可能な箇所:

  • クライアントコンポーネント内'use client' ディレクティブ付き)
  • PWAのメインナビゲーション部分
  • ダッシュボードやマイページなど、認証後のエリア

Next.jsが自動でやってくれること:

  • ✅ ページ間のプリフェッチ(<Link>コンポーネント)
  • ✅ ルートレベルのキャッシング
  • ✅ SSR/SSGによる初期表示の高速化

この保持パターンが有効な場面:

  • ❌ 初回ロード:Next.jsのSSR/SSGを活用
  • ✅ ページ遷移後のクライアントサイドナビゲーション:保持パターンで高速化
// Next.js App Router での実装例
'use client'

import { usePathname } from 'next/navigation'

export default function ClientLayout({ children }) {
  const pathname = usePathname()

  const isSearchPage = pathname === '/app/search'
  const isMyListPage = pathname === '/app/mylist'
  const isSettingsPage = pathname === '/app/settings'

  return (
    <>
      {/* クライアントサイドで状態を保持 */}
      <div className={isSearchPage ? 'block' : 'hidden'}>
        <SearchPage />
      </div>
      <div className={isMyListPage ? 'block' : 'hidden'}>
        <MyListPage />
      </div>
      <div className={isSettingsPage ? 'block' : 'hidden'}>
        <SettingsPage />
      </div>
    </>
  )
}

重要:Next.jsのルーティング機能との共存

  • 初回アクセスはNext.jsのルーティングで処理(SSR/SSGの恩恵)
  • その後のナビゲーションはこの保持パターンで高速化
  • 両方の利点を活かすハイブリッド構成が理想的

実装の詳細

1. ナビゲーションハンドラー

ナビゲーションボタン(「検索」「マイリスト」など)をクリックしたときの挙動を制御します。

重要な考え方:詳細ページから戻るときは、ブラウザの「戻る」ボタンを使う

これにより、display: noneで隠れていたページが再表示され、状態がそのまま復元されます。

// useNavigationHandler.ts
import { useCallback } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'

const navigateToSearch = useCallback((e?: React.MouseEvent) => {
  if (e) e.preventDefault()

  // 現在のページが詳細ページかどうか判定
  const isDetailPage = location.pathname.startsWith('/openchat/')

  if (isDetailPage) {
    // ケース1: 詳細ページから「検索」ボタンを押した
    // → ブラウザバック(-1)で前のページに戻る
    // → display: none だった検索ページが display: block になる
    // → 検索結果とスクロール位置がそのまま表示される

    if (window.history.state?.idx > 0) {
      // 履歴がある場合は、ブラウザバックで戻る
      navigate(-1)
    } else {
      // 直接URLアクセスの場合(履歴がない)は、フォールバック処理
      // sessionStorageから検索キーワードを復元
      const savedQuery = sessionStorage.getItem('searchPageQuery')
      if (savedQuery) {
        navigate(`/?q=${encodeURIComponent(savedQuery)}`)
      } else {
        navigate('/')
      }
    }
  } else if (location.pathname === '/') {
    // ケース2: 検索ページで「検索」ボタンを押した
    // → 検索条件をリセット(空の検索ページに戻る)
    sessionStorage.removeItem('searchPageQuery')
    navigate('/', { replace: true })
  } else {
    // ケース3: マイリストや設定ページから「検索」ボタンを押した
    // → 保存されていた検索クエリで検索ページに遷移
    const savedQuery = sessionStorage.getItem('searchPageQuery')
    if (savedQuery) {
      navigate(`/?q=${encodeURIComponent(savedQuery)}`)
    } else {
      navigate('/')
    }
  }
}, [location.pathname, navigate])

各要素の解説:

コード 説明
window.history.state?.idx ブラウザの履歴スタックのインデックス。0より大きければ履歴がある
navigate(-1) ブラウザの「戻る」ボタンと同じ動作。前のページに戻る
sessionStorage タブ/ウィンドウごとに保存される一時ストレージ。直接アクセス対策
{ replace: true } 履歴に新しいエントリを追加せず、現在のエントリを置き換える

2. 検索状態の保存(sessionStorage)

基本設計:サイト内ナビゲーションボタンの動作

サイト内のナビゲーションボタン(「検索」「マイリスト」「設定」)は、URLパラメータなしのベースURLに遷移します:

  • 「検索」ボタン → /
  • 「マイリスト」ボタン → /mylist
  • 「設定」ボタン → /settings

その後、画面の状態に応じてURLが書き換えられます

1. 「検索」ボタンをクリック
2. / に遷移(ベースURL)
3. 他のページが display: none、検索ページが display: block に切り替わる(既にマウント済み)
4. sessionStorageから最後の検索キーワードを取得(例:'react')
5. URLを /?q=react に書き換え
6. URLパラメータの変化を検知して検索が実行される(または既存の検索結果がそのまま表示される)

sessionStorageの基本の役割:
ナビゲーションボタンをクリックしたとき、最後の検索状態を復元してURLに反映する

動作例:

ケース1: 検索 → 詳細 → ブラウザバック

/?q=react(検索)→ /openchat/123(詳細)→ ブラウザバック
→ /?q=react(履歴にURLパラメータが残っている)

→ sessionStorage不要(履歴で復元)

ケース2: 検索 → 詳細 → マイリスト → 「検索」ボタン

/?q=react(検索)→ /openchat/123(詳細)→ /mylist(マイリスト)
→ 「検索」ボタンをクリック
→ / に遷移 → sessionStorageから 'react' を取得 → /?q=react に書き換え

sessionStorageが基本の役割を果たす

ケース3: URL直接アクセス → 「検索」ボタン

直接 /openchat/123 にアクセス
→ 「検索」ボタンをクリック
→ / に遷移 → sessionStorageから 'react' を取得(以前のセッションで保存済み)→ /?q=react に書き換え

→ sessionStorageが機能(ただし任意の仕様)

重要:

  • 基本の役割:ナビゲーションボタンで最後の検索状態を復元
  • 任意の仕様:別画面(詳細ページなど)でリロード後、ナビゲーションボタンで検索画面に戻ったときに検索状態を復元(あえてそういう仕様にしたが、必須ではない)
  • sessionStorageがない場合:「検索」ボタン → /(空の検索ページ)に遷移

補足:同じ画面でのリロードについて

  • 検索ページ(/?q=react)でリロード → URLパラメータ(?q=react)が残っているため、自動的に検索が復元される
  • sessionStorage不要(URLパラメータで復元される)
// SearchPage.tsx
import { useCallback } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'

function SearchPage() {
  const navigate = useNavigate()
  const [searchParams] = useSearchParams()
  const urlKeyword = searchParams.get('q') || ''  // URLから検索キーワードを取得(例:"react")

  // 検索結果のカードをクリックしたときのハンドラー
  const handleCardClick = useCallback((chatId: number) => {
    // ステップ1: 詳細ページに遷移する前に、現在の検索キーワードをsessionStorageに保存
    // 例: sessionStorage.setItem('searchPageQuery', 'react')
    if (urlKeyword) {
      sessionStorage.setItem('searchPageQuery', urlKeyword)
    }

    // ステップ2: 詳細ページに遷移
    navigate(`/openchat/${chatId}`)

    // この後、詳細ページで「検索」ボタンをクリックすると:
    // → navigateToSearch が sessionStorage から 'react' を取得
    // → navigate('/?q=react') で検索ページに遷移
    // → SearchPage がマウントされ、URLパラメータ 'react' で検索を実行
  }, [navigate, urlKeyword])

  return (
    <div>
      {/* 検索結果の表示 */}
      {results.map(chat => (
        <div key={chat.id} onClick={() => handleCardClick(chat.id)}>
          {chat.name}
        </div>
      ))}
    </div>
  )
}

動作フローの確認:

1. /?q=react で検索(検索ページは display: block)
2. 検索結果のカードをクリック
3. sessionStorage.setItem('searchPageQuery', 'react')  // キーワードを保存
4. /openchat/123 に遷移(検索ページは display: none に、詳細ページが表示される)
5. マイリストに移動 → /mylist(検索ページは display: none のまま)
6. 「検索」ボタンをクリック
7. / に遷移(ベースURL)
8. マイリストが display: none、検索ページが display: block に切り替わる
9. navigateToSearch が sessionStorage から 'react' を取得
10. navigate('/?q=react', { replace: true }) でURLを書き換え
11. URLパラメータを検知して検索が実行される(または既存の結果がそのまま表示)

基本設計のポイント:

  • ナビゲーションボタンは常にベースURL(/, /mylist, /settings)に遷移
  • ページはdisplay切り替えで表示される(既にマウント済み)
  • sessionStorageから状態を取得してURLを書き換える
  • これにより、「最後の検索状態」が復元される

sessionStorageを使う理由:

ストレージ 特徴 適用
sessionStorage タブごとに独立。タブを閉じると消える ✅ 今回採用
localStorage 全タブで共有。永続的 ❌ 複数タブで混乱する
Redux/Zustand グローバル状態管理 ❌ リロードで消える

3. メインページと詳細ページの使い分け

ページ種別 実装方式 理由
検索ページ display 切り替え 検索結果とスクロール位置を保持
マイリスト display 切り替え フィルター設定とソート順を保持
設定ページ display 切り替え 入力中のフォーム内容を保持
詳細ページ 真のオーバーレイ 毎回最新データを取得する必要があるため

復元パターン vs 保持パターンの実装比較

アプローチ1:復元パターン(前回の記事)

実装:

// コンポーネントはアンマウント→再マウントされる
<Routes>
  <Route path="/" element={<SearchPage />} />
  <Route path="/mylist" element={<MyListPage />} />
</Routes>

function SearchPage() {
  // ページ数をMapで保存し、再マウント時に復元
  const pageSizes = useRef(new Map())
  const currentKey = location.key
  const savedSize = pageSizes.current.get(currentKey) ?? 1

  const { data } = useSWRInfinite(
    (index) => `/api/search?page=${index}&q=${keyword}`,
    fetcher,
    {
      initialSize: savedSize,      // ページ数を復元
      persistSize: true,            // sizeのリセット防止
      revalidateAll: false          // 全ページ再検証を抑制
    }
  )

  // スクロール位置は <ScrollRestoration /> が自動復元
}

動作フロー:

  1. 検索ページ → 詳細ページ(SearchPageアンマウント)
  2. 詳細ページ → 戻る(SearchPage再マウント)
  3. SWRキャッシュからデータ復元 + スクロール位置復元
  4. 一瞬のちらつき(再レンダリング)が発生

アプローチ2:保持パターン(今回の記事)

実装:

// コンポーネントはマウントされたまま、display切り替え
<div className={isSearchPage ? 'block' : 'hidden'}>
  <SearchPage />
</div>
<div className={isMyListPage ? 'block' : 'hidden'}>
  <MyListPage />
</div>

function SearchPage() {
  // 通常のデータフェッチ(復元処理不要)
  const { data } = useSWRInfinite(
    (index) => `/api/search?page=${index}&q=${keyword}`,
    fetcher
  )

  // 状態は保持されたまま(何もしなくてOK)
}

動作フロー:

  1. 検索ページ → 詳細ページ(SearchPageはdisplay: noneで隠れる)
  2. 詳細ページ → 戻る(SearchPageがdisplay: blockで表示される)
  3. データもスクロール位置もそのまま(復元処理不要)
  4. ちらつきなし(再レンダリング不要)

パフォーマンス・実装コスト比較

項目 復元パターン 保持パターン 差分
画面切り替え速度 100〜300ms ~16ms 最大20倍速い
再レンダリング あり なし ちらつきなし
体感速度 一瞬の遅延 瞬時 ネイティブアプリ並み
実装コスト 高い(復元ロジック必要) 低い(display切り替えのみ) 大幅に簡単
メモリ使用量 低い やや高い 3〜5ページなら許容範囲
適用可能ページ数 無制限 3〜5ページ程度 -
データ再取得 キャッシュから復元 不要(保持) -
スクロール位置 ScrollRestorationで復元 不要(保持) -

注意点とトレードオフ

メモリ使用量

デメリット: すべてのページが常にマウントされているため、メモリ使用量は増加します。

通常のルーティング: 1ページ分のメモリ
display切り替え:    3ページ分のメモリ

対策:

  • メインページのみこの方式を採用(3〜5ページ程度)
  • 詳細ページや低頻度のページは通常のルーティング
  • 大量の画像を含むページは遅延ロード

初期ロード時間

すべてのメインページを最初にマウントするため、初期表示が若干遅くなる可能性があります。

対策:

// 遅延マウント(最初は検索ページのみ)
const [mountedPages, setMountedPages] = useState({
  search: true,
  mylist: false,
  settings: false
})

useEffect(() => {
  // 他のページを段階的にマウント
  setTimeout(() => {
    setMountedPages(prev => ({ ...prev, mylist: true }))
  }, 100)
  setTimeout(() => {
    setMountedPages(prev => ({ ...prev, settings: true }))
  }, 200)
}, [])

SEO

この実装はクライアントサイドレンダリング(CSR)を前提としています。

  • 初回アクセス時は、JavaScriptでページがマウントされるため、通常のSPAと同じSEO特性
  • display: none で隠されたコンテンツがあっても、SEO上は特に有利にならない
  • SSR(サーバーサイドレンダリング)を使っている場合は、初期HTMLに複数ページのコンテンツが含まれるため、ファイルサイズが増加する点に注意

結論:SEOへの影響は通常のSPAと変わらないため、特別な考慮は不要です。

2つのアプローチの使い分けガイド

保持パターン(今回の記事)を選ぶべきケース

✅ こんなアプリに最適:

  • PWA(Progressive Web App):ネイティブアプリのような体験が最重要
    • インストール可能なアプリとしての使い勝手
    • オフライン対応時の状態保持
    • タブ切り替えのような瞬時の遷移
  • 検索系アプリ:検索結果の保持が最重要
  • ダッシュボード:複数の画面を頻繁に行き来する
  • 管理画面(3〜5ページ程度):フォーム入力中の状態を保持したい
  • メインページが少ない:3〜5ページ程度のコアページがある

❌ 避けるべきケース:

  • 10ページ以上のメインページがある
  • 各ページが重い(大量の画像・動画・複雑なグラフなど)
  • メモリ制限が厳しいデバイス(古いスマホなど)
  • 初回SSR/SSGが必須のマーケティングサイト

復元パターン(前回の記事)を選ぶべきケース

✅ こんなアプリに最適:

  • 大規模サイト:数十〜数百のページがある
  • 重いページ:データ量が多い、複雑なコンポーネント
  • メモリ効率重視:低スペックデバイスをターゲットにする
  • SEO重視:各ページを完全に独立させたい

❌ 避けるべきケース:

  • 極限まで高速な画面切り替えが必要
  • 復元ロジックの実装コストをかけたくない
  • 状態管理が複雑(復元が難しい)

ハイブリッド戦略(推奨)

最も効果的なのは、2つのアプローチを組み合わせることです:

function App() {
  const location = useLocation()

  // メインページ(3つ)は保持パターン
  const isSearchPage = location.pathname === '/'
  const isMyListPage = location.pathname === '/mylist'
  const isSettingsPage = location.pathname === '/settings'

  // その他のページは復元パターン(通常のルーティング)
  const showOtherPages = !isSearchPage && !isMyListPage && !isSettingsPage

  return (
    <>
      {/* コアページ:保持パターン(瞬時切り替え) */}
      <div className={isSearchPage ? 'block' : 'hidden'}>
        <SearchPage />
      </div>
      <div className={isMyListPage ? 'block' : 'hidden'}>
        <MyListPage />
      </div>
      <div className={isSettingsPage ? 'block' : 'hidden'}>
        <SettingsPage />
      </div>

      {/* サブページ:復元パターン(メモリ効率) */}
      {showOtherPages && (
        <Routes>
          <Route path="/openchat/:id" element={<DetailPage />} />
          <Route path="/about" element={<AboutPage />} />
          <Route path="/help" element={<HelpPage />} />
        </Routes>
      )}
    </>
  )
}

この戦略により:

  • ✅ ユーザーが頻繁に使うページは高速切り替え
  • ✅ それ以外のページはメモリ効率を優先
  • ✅ 最適なUXとリソース使用のバランス

実装のベストプラクティス

1. z-indexの管理

複数のページが重なるので、z-indexの設計が重要です:

.main-page {
  z-index: 10;
}

.detail-page-overlay {
  z-index: 50;
}

.modal {
  z-index: 100;
}

2. レイアウトシフトの防止

display: nonedisplay: block を切り替える際、レイアウトシフトが起きないように:

<div
  className={isSearchPage ? 'block' : 'hidden'}
  style={{
    position: 'absolute',
    top: 0,
    left: 0,
    right: 0,
    minHeight: '100vh'
  }}
>
  <SearchPage />
</div>

3. 不要な副作用の制御

非表示のページでタイマーやWebSocket接続が動き続けないように:

function SearchPage() {
  const isVisible = usePageVisibility()

  useEffect(() => {
    if (!isVisible) return

    // 表示中のみAPI polling
    const interval = setInterval(() => {
      fetchData()
    }, 30000)

    return () => clearInterval(interval)
  }, [isVisible])
}

// カスタムフック
function usePageVisibility() {
  const location = useLocation()
  return location.pathname === '/'
}

💡 React 19.2以降の <Activity /> なら自動制御

<Activity /> は useEffect のクリーンアップが自動実行されるので、手動制御不要です。

実装例

function SearchPage() {
  // Activity が自動で制御してくれる

  useEffect(() => {
    // 表示中のみAPIポーリング(自動)
    const interval = setInterval(() => {
      fetchData()
    }, 30000)

    return () => clearInterval(interval)
  }, [])  // ← シンプルな依存配列
}

まとめ

SPAで状態を維持する2つのアプローチを紹介しました:

速度比較

アプローチ 画面切り替え速度
復元パターン(前回の記事) 100〜300ms
保持パターン(今回の記事) ~16ms(最大20倍速い)

復元パターン(前回の記事)

  • コンポーネントはアンマウント→再マウント
  • SWRキャッシュ + ScrollRestorationで状態を復元
  • 画面切り替え:100〜300ms
  • メモリ効率が良く、大規模サイトに向く
  • 復元ロジックの実装コストがかかる

保持パターン(今回の記事)

  • コンポーネントは常にマウント
  • display: none/block で切り替え
  • 画面切り替え:~16ms(圧倒的に速い)
  • 状態が保持され、復元処理不要
  • 実装が簡単で、ちらつきなし
  • メモリ使用量はやや増加(3〜5ページ程度なら許容範囲)

推奨される戦略

ハイブリッドアプローチが最適:

  • コアページ(検索・マイリスト・設定など)→ 保持パターン(最大20倍高速)
  • サブページ(詳細・ヘルプなど)→ 復元パターン(メモリ効率)

この組み合わせにより、ネイティブアプリのような瞬時の画面切り替えと、リソース使用のバランスが取れたSPAを実現できます。

参考リンク

前回の記事(復元パターン)

公式ドキュメント


この記事が「SPAで状態が消えて困っている」「PWAでネイティブアプリのようなUXを実現したい」という方の参考になれば幸いです!

2つのアプローチを適材適所で使い分けることで、最高のUXを実現しましょう。

質問やフィードバックがあれば、コメント欄でお気軽にどうぞ 👋

0
2
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?