Documentation Next.js

はじめに

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でブラウザバック時に状態を保持するには、複数のアプローチがあります。

  1. useRouterのbeforePopState - ブラウザバック時に確認ダイアログを表示し、誤った操作を防止
  2. sessionStorage - シンプルにブラウザセッション中の状態を永続化
  3. Recoil - 複雑なグローバル状態をコンポーネント間で共有
  4. URLパラメータ - 状態をURLに反映させ、共有・ブックマークを可能に

ユースケースに応じて適切な方法を選択し、組み合わせることで、ユーザーにストレスのないナビゲーション体験を提供できます。

参考文献

円