はじめに
以前、「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切り替えを完全に制御できる。
*/}
</>
)
}
重要なポイント:
-
<Routes>を使わない:通常のReact Routerのルーティングは使わず、location.pathnameで自前判定 - 常にマウント:3つのメインページは初回レンダリング時に全てマウントされる
-
display切り替え:ページ遷移時は
display: none/blockを切り替えるだけ - オーバーレイは別扱い:詳細ページなど、毎回新しいデータが必要なページは条件付きレンダリング
💡 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 /> が自動復元
}
動作フロー:
- 検索ページ → 詳細ページ(SearchPageアンマウント)
- 詳細ページ → 戻る(SearchPage再マウント)
- SWRキャッシュからデータ復元 + スクロール位置復元
- 一瞬のちらつき(再レンダリング)が発生
アプローチ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)
}
動作フロー:
- 検索ページ → 詳細ページ(SearchPageは
display: noneで隠れる) - 詳細ページ → 戻る(SearchPageが
display: blockで表示される) - データもスクロール位置もそのまま(復元処理不要)
- ちらつきなし(再レンダリング不要)
パフォーマンス・実装コスト比較
| 項目 | 復元パターン | 保持パターン | 差分 |
|---|---|---|---|
| 画面切り替え速度 | 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: none と display: 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を実現しましょう。
質問やフィードバックがあれば、コメント欄でお気軽にどうぞ 👋