はじめに
Next.jsでWebアプリケーションを開発する際、ブラウザの「戻る」ボタンを押したときに入力フォームの内容やスクロール位置、フィルター条件などの状態が失われてしまう問題に直面したことはないでしょうか。
この記事では、Next.jsでブラウザバック時に状態を保持するための具体的な方法を、以下の4つのアプローチから解説します。
- useRouterとbeforePopState - ブラウザバックイベントのカスタム制御
- sessionStorage - ブラウザセッション中のデータ永続化
- Recoil - グローバル状態管理ライブラリの活用
- URLパラメータ - URLベースの状態管理
それぞれのアプローチにはメリット・デメリットがあるため、ユースケースに応じて最適な方法を選択してください。
ブラウザバック時に状態が失われる原因
Next.jsはSPA(Single Page Application)として動作しますが、ページ遷移時にコンポーネントがアンマウントされると、useStateで管理していた状態は初期化されます。
// このコンポーネントの状態は、ページ遷移で失われる
const SearchPage = () => {
const [searchQuery, setSearchQuery] = useState('');
const [results, setResults] = useState([]);
// ユーザーが検索後、詳細ページへ移動し、
// ブラウザバックすると searchQuery と results は初期値に戻る
return (
<div>
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{/* 検索結果の表示 */}
</div>
);
};
この問題を解決するために、以下の方法を見ていきましょう。
useRouterとbeforePopStateの活用
beforePopStateとは
beforePopStateは、Next.jsのuseRouterフックが提供するメソッドで、ブラウザの履歴操作(戻る・進む)が発生する直前にカスタム処理を実行できます。
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
const FormPage = () => {
const router = useRouter();
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
});
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
useEffect(() => {
// beforePopStateでブラウザバック時の処理をカスタマイズ
router.beforePopState(({ url, as, options }) => {
// 未保存の変更がある場合、確認ダイアログを表示
if (hasUnsavedChanges) {
const confirmed = window.confirm(
'入力内容が保存されていません。このページを離れますか?'
);
if (!confirmed) {
// キャンセルされた場合、現在のURLを維持
// history.pushStateで履歴を元に戻す
window.history.pushState(null, '', router.asPath);
return false; // ナビゲーションをキャンセル
}
}
return true; // ナビゲーションを許可
});
// クリーンアップ: コンポーネントのアンマウント時にリセット
return () => {
router.beforePopState(() => true);
};
}, [router, hasUnsavedChanges]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData(prev => ({
...prev,
[e.target.name]: e.target.value
}));
setHasUnsavedChanges(true);
};
return (
<form>
<input
name="name"
value={formData.name}
onChange={handleInputChange}
placeholder="お名前"
/>
<input
name="email"
value={formData.email}
onChange={handleInputChange}
placeholder="メールアドレス"
/>
<textarea
name="message"
value={formData.message}
onChange={handleInputChange}
placeholder="メッセージ"
/>
<button type="submit">送信</button>
</form>
);
};
export default FormPage;
beforePopStateの注意点
beforePopStateは Pages Router でのみ使用可能です- App Router を使用している場合は、別のアプローチが必要です
return falseでナビゲーションをキャンセルできますが、ブラウザのURLは一瞬変わってしまうことがあります
sessionStorageを使った状態保持
sessionStorageとは
sessionStorageは、ブラウザのタブが開いている間だけデータを保持するWeb Storage APIです。ページをリロードしてもデータは維持されますが、タブを閉じると消去されます。
import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
// sessionStorageのキーを定数化
const STORAGE_KEY = 'searchPageState';
interface SearchState {
query: string;
filters: string[];
page: number;
}
const SearchPage = () => {
const router = useRouter();
// 初期状態をsessionStorageから復元
const [state, setState] = useState<SearchState>(() => {
// サーバーサイドでは実行しない
if (typeof window === 'undefined') {
return { query: '', filters: [], page: 1 };
}
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
return JSON.parse(saved);
} catch {
return { query: '', filters: [], page: 1 };
}
}
return { query: '', filters: [], page: 1 };
});
// 状態が変更されたらsessionStorageに保存
useEffect(() => {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}, [state]);
// ページ離脱時のクリーンアップ(必要に応じて)
useEffect(() => {
const handleBeforeUnload = () => {
// 完全にページを離れる場合は状態をクリア
// sessionStorage.removeItem(STORAGE_KEY);
};
// ルート変更時の処理
const handleRouteChange = (url: string) => {
// 検索関連ページ以外に遷移する場合は状態をクリア
if (!url.startsWith('/search')) {
sessionStorage.removeItem(STORAGE_KEY);
}
};
router.events.on('routeChangeStart', handleRouteChange);
return () => {
router.events.off('routeChangeStart', handleRouteChange);
};
}, [router]);
const updateQuery = (query: string) => {
setState(prev => ({ ...prev, query, page: 1 }));
};
const toggleFilter = (filter: string) => {
setState(prev => ({
...prev,
filters: prev.filters.includes(filter)
? prev.filters.filter(f => f !== filter)
: [...prev.filters, filter],
page: 1
}));
};
const setPage = (page: number) => {
setState(prev => ({ ...prev, page }));
};
return (
<div>
<input
value={state.query}
onChange={(e) => updateQuery(e.target.value)}
placeholder="検索キーワード"
/>
<div>
{['カテゴリA', 'カテゴリB', 'カテゴリC'].map(filter => (
<label key={filter}>
<input
type="checkbox"
checked={state.filters.includes(filter)}
onChange={() => toggleFilter(filter)}
/>
{filter}
</label>
))}
</div>
<div>
現在のページ: {state.page}
<button onClick={() => setPage(state.page + 1)}>次のページ</button>
</div>
</div>
);
};
export default SearchPage;
カスタムフックとして抽出
再利用性を高めるために、カスタムフックとして切り出すことをおすすめします。
import { useState, useEffect } from 'react';
function useSessionState<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [state, setState] = useState<T>(() => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const saved = sessionStorage.getItem(key);
return saved ? JSON.parse(saved) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
sessionStorage.setItem(key, JSON.stringify(state));
}, [key, state]);
return [state, setState];
}
// 使用例
const MyComponent = () => {
const [formData, setFormData] = useSessionState('myForm', {
name: '',
email: ''
});
return (
<input
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
/>
);
};
Recoilを使ったグローバル状態管理
Recoilとは
Recoilは、Facebookが開発したReact用の状態管理ライブラリです。atomと呼ばれる単位で状態を管理し、コンポーネント間で簡単に状態を共有できます。
npm install recoil
基本的なRecoilの設定
// atoms/searchAtom.ts
import { atom } from 'recoil';
export interface SearchState {
query: string;
filters: string[];
sortBy: 'date' | 'relevance' | 'price';
page: number;
}
// atomを定義
export const searchStateAtom = atom<SearchState>({
key: 'searchState', // ユニークなキー
default: {
query: '',
filters: [],
sortBy: 'relevance',
page: 1
}
});
// _app.tsxでRecoilRootをラップ
// pages/_app.tsx
import { RecoilRoot } from 'recoil';
import type { AppProps } from 'next/app';
function MyApp({ Component, pageProps }: AppProps) {
return (
<RecoilRoot>
<Component {...pageProps} />
</RecoilRoot>
);
}
export default MyApp;
Recoilを使ったコンポーネント
// pages/search.tsx
import { useRecoilState } from 'recoil';
import { searchStateAtom } from '../atoms/searchAtom';
import Link from 'next/link';
const SearchPage = () => {
// Recoilの状態を使用
const [searchState, setSearchState] = useRecoilState(searchStateAtom);
const updateQuery = (query: string) => {
setSearchState(prev => ({ ...prev, query }));
};
const toggleFilter = (filter: string) => {
setSearchState(prev => ({
...prev,
filters: prev.filters.includes(filter)
? prev.filters.filter(f => f !== filter)
: [...prev.filters, filter]
}));
};
return (
<div>
<h1>商品検索</h1>
<input
value={searchState.query}
onChange={(e) => updateQuery(e.target.value)}
placeholder="検索キーワード"
/>
<div>
<h3>フィルター</h3>
{['新着', 'セール', '送料無料'].map(filter => (
<label key={filter}>
<input
type="checkbox"
checked={searchState.filters.includes(filter)}
onChange={() => toggleFilter(filter)}
/>
{filter}
</label>
))}
</div>
{/* 検索結果のリスト */}
<ul>
<li>
<Link href="/products/1">商品1の詳細へ</Link>
</li>
<li>
<Link href="/products/2">商品2の詳細へ</Link>
</li>
</ul>
<p>ブラウザバックしても検索条件は保持されます</p>
</div>
);
};
export default SearchPage;
Recoil Syncで永続化
Recoilの状態をsessionStorageやURLと同期させるには、recoil-syncパッケージを使用します。
npm install recoil-sync
// atoms/persistedSearchAtom.ts
import { atom } from 'recoil';
import { syncEffect } from 'recoil-sync';
export const persistedSearchAtom = atom({
key: 'persistedSearch',
default: {
query: '',
filters: []
},
effects: [
syncEffect({
storeKey: 'session-store',
refine: (value) => value // バリデーション
})
]
});
URLパラメータで状態を管理
URLパラメータのメリット
URLパラメータで状態を管理すると、以下のメリットがあります。
- 共有可能 - URLをコピーして他の人と状態を共有できる
- ブックマーク可能 - 検索条件などをブックマークに保存できる
- SEO対応 - 検索エンジンがページをインデックスしやすい
- ブラウザバック対応 - ブラウザの履歴機能とネイティブに連携
モーダルの状態をURLで管理
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
const ProductListPage = () => {
const router = useRouter();
const [selectedProduct, setSelectedProduct] = useState<string | null>(null);
// URLパラメータからモーダルの状態を復元
useEffect(() => {
const { modal, productId } = router.query;
if (modal === 'detail' && typeof productId === 'string') {
setSelectedProduct(productId);
} else {
setSelectedProduct(null);
}
}, [router.query]);
// モーダルを開く(URLを更新)
const openModal = (productId: string) => {
router.push(
{
pathname: router.pathname,
query: { ...router.query, modal: 'detail', productId }
},
undefined,
{ shallow: true } // ページを再読み込みせずにURLを更新
);
};
// モーダルを閉じる(URLを更新)
const closeModal = () => {
const { modal, productId, ...restQuery } = router.query;
router.push(
{
pathname: router.pathname,
query: restQuery
},
undefined,
{ shallow: true }
);
};
return (
<div>
<h1>商品一覧</h1>
<ul>
{['product-1', 'product-2', 'product-3'].map(id => (
<li key={id}>
<button onClick={() => openModal(id)}>
{id}の詳細を見る
</button>
</li>
))}
</ul>
{/* モーダル */}
{selectedProduct && (
<div className="modal-overlay" onClick={closeModal}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h2>商品詳細: {selectedProduct}</h2>
<p>この状態はURLに保存されています。</p>
<p>ブラウザバックでモーダルが閉じます。</p>
<button onClick={closeModal}>閉じる</button>
</div>
</div>
)}
<style jsx>{`
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
padding: 2rem;
border-radius: 8px;
max-width: 500px;
}
`}</style>
</div>
);
};
export default ProductListPage;
検索フィルターをURLで管理
import { useRouter } from 'next/router';
import { useCallback, useMemo } from 'react';
const SearchWithURL = () => {
const router = useRouter();
// URLパラメータから状態を取得
const searchState = useMemo(() => {
const { q, filters, sort, page } = router.query;
return {
query: typeof q === 'string' ? q : '',
filters: typeof filters === 'string'
? filters.split(',').filter(Boolean)
: [],
sort: typeof sort === 'string' ? sort : 'relevance',
page: typeof page === 'string' ? parseInt(page, 10) : 1
};
}, [router.query]);
// URLパラメータを更新する関数
const updateURL = useCallback((updates: Record<string, string | number | string[]>) => {
const newQuery = { ...router.query };
Object.entries(updates).forEach(([key, value]) => {
if (Array.isArray(value)) {
if (value.length > 0) {
newQuery[key] = value.join(',');
} else {
delete newQuery[key];
}
} else if (value) {
newQuery[key] = String(value);
} else {
delete newQuery[key];
}
});
router.push({ pathname: router.pathname, query: newQuery }, undefined, {
shallow: true
});
}, [router]);
const handleSearch = (query: string) => {
updateURL({ q: query, page: 1 });
};
const toggleFilter = (filter: string) => {
const newFilters = searchState.filters.includes(filter)
? searchState.filters.filter(f => f !== filter)
: [...searchState.filters, filter];
updateURL({ filters: newFilters, page: 1 });
};
const handleSort = (sort: string) => {
updateURL({ sort, page: 1 });
};
const handlePageChange = (page: number) => {
updateURL({ page });
};
return (
<div>
<input
value={searchState.query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="検索"
/>
<div>
{['カテゴリA', 'カテゴリB', 'カテゴリC'].map(filter => (
<label key={filter}>
<input
type="checkbox"
checked={searchState.filters.includes(filter)}
onChange={() => toggleFilter(filter)}
/>
{filter}
</label>
))}
</div>
<select
value={searchState.sort}
onChange={(e) => handleSort(e.target.value)}
>
<option value="relevance">関連度順</option>
<option value="date">新着順</option>
<option value="price">価格順</option>
</select>
<div>
<button
disabled={searchState.page === 1}
onClick={() => handlePageChange(searchState.page - 1)}
>
前のページ
</button>
<span>ページ {searchState.page}</span>
<button onClick={() => handlePageChange(searchState.page + 1)}>
次のページ
</button>
</div>
</div>
);
};
export default SearchWithURL;
アプローチの比較と選択基準
| アプローチ | 永続性 | 共有性 | 実装難易度 | 適したユースケース |
|---|---|---|---|---|
| beforePopState | なし | なし | 低 | フォーム離脱防止 |
| sessionStorage | タブ内 | なし | 低 | 一時的な状態保持 |
| Recoil | メモリ内 | なし | 中 | 複雑なグローバル状態 |
| URLパラメータ | ブックマーク可 | あり | 中 | 検索・フィルター |
選択の指針
- フォームの入力保護 →
beforePopStateで確認ダイアログを表示 - 検索条件の保持 → URLパラメータまたはsessionStorage
- 共有可能にしたい → URLパラメータ
- 複数コンポーネントで状態を共有 → Recoil + sessionStorage
まとめ
Next.jsでブラウザバック時に状態を保持するには、複数のアプローチがあります。
- useRouterのbeforePopState - ブラウザバック時に確認ダイアログを表示し、誤った操作を防止
- sessionStorage - シンプルにブラウザセッション中の状態を永続化
- Recoil - 複雑なグローバル状態をコンポーネント間で共有
- URLパラメータ - 状態をURLに反映させ、共有・ブックマークを可能に
ユースケースに応じて適切な方法を選択し、組み合わせることで、ユーザーにストレスのないナビゲーション体験を提供できます。