【Next.js】ブラウザバックで前画面の情報が消える問題を解消しよう！

# はじめに

お疲れ様です！<a href="https://x.com/Kei_dev_1213">Kei_dev_1213</a>と申します！
現在、絶賛Next.jsを使って個人開発中の私ですが、本記事ではタイトルのような問題に直面しましたので、その解決方法について記事にしたいと思います！

# 問題
さて、問題自体はタイトルの通りなのですが、前画面の情報が消えるとはどういうことなのか、適当に画面を作ってみたのでまずは実際の動きを見てみましょう。
ここでは例として、`①検索→②一覧表示→③詳細画面に遷移→④ブラウザバックで検索画面に戻る` という流れを見ていくこととします。

- ①検索画面（フォーム未入力、検索前の状態）
![スクリーンショット 2024-12-04 21.21.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3832049/9f86a6c0-96d4-d48e-39e0-1cbb2e061e13.png)

- ②検索画面（フォーム入力済、検索後の状態）
![スクリーンショット 2024-12-04 21.35.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3832049/35e62961-28c1-333f-63b2-100dc21c83d8.png)

- ③詳細画面に遷移
![スクリーンショット 2024-12-04 21.36.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3832049/c1f56ac2-7b0b-b899-15e8-89e3fee75505.png)

- ④ブラウザバックで検索画面に戻る
![スクリーンショット 2024-12-04 21.38.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3832049/01368e27-856a-8e92-80e0-f90f3ab442f8.png)
→このタイミングで、②で入力した検索フォームの値と、検索結果が表示されていてほしいのですが、フォームの値と検索結果が消えてしまっていますね、、
これでも正直機能としては問題は無いのですが、正直一般ユーザーからしたら使いにくいシステムになってしまいます。

では次に、検索処理の実装の中身を見てみましょう。

- 検索画面
```tsx:FruitSearch.tsx（一部）
〜（中略）〜

  // 検索
  const handleSearch = async (formdata: FormData) => {
    // 検索
    const results = await searchAction(formdata);
    // 結果を設定
    setSearchResults(results);
  };

  return (
    <div className="container mx-auto p-4">
      <h1 className="text-2xl font-bold mb-4">果物検索</h1>
      <form action={handleSearch} className="space-y-4 mb-8">
〜（中略）〜
```

- サーバー処理
```ts:action.tsx（一部）
〜（中略）〜
// 検索
export const searchAction = async (formdata: FormData) => {
  "use server";

  // フォームの値の取得
  const name = formdata.get("name");

  // 結果の取得
  const results: Fruit[] = [
    { name: "りんご", color: "赤", taste: "甘酸っぱい", origin: "青森", season: "秋" },
    { name: "バナナ", color: "黄", taste: "甘い", origin: "フィリピン", season: "通年" },
    { name: "オレンジ", color: "オレンジ", taste: "酸っぱい", origin: "カリフォルニア", season: "冬" },
  ].filter((fruit) => fruit.name === name);

  // 返却
  return results;
};
〜（中略）〜
```

説明に必要なところ以外は中略としていますが、要は、フォームのactionで実行されるhandleSearchメソッドから、server actionでsearchActionメソッドを呼び出して結果を取得し、画面上のstateに設定しています。
※実際はDBや外部のシステムからデータを取ってくると思いますがここでは簡略化して、フォームの一部の値と一致すればそのデータを返却する形としています。

ここで、<font color="Red">`上の例ではブラウザバック時にうまく値が戻らなかった理由が分かります`</font>ね。つまり、ブラウザバックを行うと前のページ全体が再読み込みされますが、今回の検索処理実行時にはJavaScriptによって画面の状態や表示内容を制御しているため、そもそも意図した動きになっていないということです（Nextに限らずSPAのアプリではあるある現象ですね）。

一見どうしようもなさそうですが、要は`ページ全体が置き換えられればよい`ということになるので、検索ボタン実行時にページを開き直して検索処理を実行させれば、ページ全体が読み込まれるのでうまく動きそうな気がします。

というわけで、今回はクエリパラメータを使ってみることとしましょう。
具体的には、`検索ボタン押下時にクエリパラメータを追加した自身のURLを読み込ませることでページを開き直し、検索画面のサーバー側の初期処理で条件に応じたデータを取得した結果を画面に表示させる`方法を試してみることとします。

とまあ文章で書いてもイマイチよく分からないなので早速実装してみましょう！↓

# 解決方法

- 検索画面の初期処理
```diff_tsx:page.tsx（一部）
〜（中略）〜
+ const page = async ({ searchParams }: { searchParams: { name: string } }) => {
+   // クエリパラメータの取得
+   const name = searchParams.name;
+   // 検索結果の取得
+   const fruits = await searchAction(name);

  return (
    // 検索画面
-    <FruitSearch />
+    <FruitSearch fruits={fruits} name={name} />
  );
};

export default page;

〜（中略）〜
```
→検索画面のサーバーサイドの初期処理です。
クエリパラメータを取得し、検索条件に応じてsearchActionから検索結果を取得し、検索画面のクライアントコンポーネントに渡しています。
※簡略化のために条件はname(果物名)だけにしています。

- サーバー処理
```diff_tsx:action.tsx（一部）
〜（中略）〜
// 検索
- export const searchAction = async (formdata: FormData) => {
-  "use server";
- 
-   // フォームの値の取得
-   const name = formdata.get("name");

+ export const searchAction = async (name: string) => {
+   if (!name) {
+     return [];
+   }
  const results: Fruit[] = [
    { name: "りんご", color: "赤", taste: "甘酸っぱい", origin: "青森", season: "秋" },
    { name: "バナナ", color: "黄", taste: "甘い", origin: "フィリピン", season: "通年" },
    { name: "オレンジ", color: "オレンジ", taste: "酸っぱい", origin: "カリフォルニア", season: "冬" },
  ].filter((fruit) => fruit.name === name);

  // 返却
  return results;
};
〜（中略）〜
```
→サーバーサイドの初期処理で呼び出される関数です。
修正前は検索ボタン押下時に呼び出されていましたが、修正後は検索画面の初期処理で呼び出すので、それに合わせて引数を変更し、条件が未設定の場合は空配列を返却しています。

- 検索画面
```diff_tsx:FruitSearch.tsx（一部）

- export default function FruitSearch() {
- const [searchResults] = useState<Fruit[]>([]);
    const [searchParams, setSearchParams] = useState({
-     name: "",
    color: "",
    taste: "",
    origin: "",
    season: "",
  });

+ export default function FruitSearch({ fruits, name }: { fruits: Fruit[]; name: string }) {
   const [searchParams, setSearchParams] = useState({
+     name: name ?? "",
    color: "",
    taste: "",
    origin: "",
    season: "",
  });

〜（中略）〜

-  // 検索
-   const handleSearch = async (formdata: FormData) => {
-     // 検索
-     const results = await searchAction(formdata);
-     // 結果を設定
-     setSearchResults(results);
-   };

+  // 検索
+   const handleSearch = async (formdata: FormData) => {
+     router.push(`/FruitSearch?name=${formdata.get("name")}`, { scroll: false });
+   };


〜（中略）〜

        <h2 className="text-xl font-semibold mb-4">検索結果</h2>        
-        {searchResults.length > 0 ? (
+        {fruits.length > 0 ? (
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
-            {searchResults.map((fruit, index) => (
+            {fruits.map((fruit, index) => (
              <Link href={`/FruitSearch/${fruit.name}`} key={index}>
                <Card className="cursor-pointer hover:opacity-80">
                  <CardContent className="p-4">
                    <h3 className="font-bold text-lg mb-2">{fruit.name}</h3>
                    <p>色: {fruit.color}</p>
                    <p>味: {fruit.taste}</p>
                    <p>原産地: {fruit.origin}</p>
                    <p>季節: {fruit.season}</p>
                  </CardContent>
                </Card>
              </Link>
            ))}
          </div>
〜（中略）〜
```
→検索画面のコンポーネントです。
fruits(検索結果)、name(検索条件)をPropsとして追加したので定義を追加します。
nameは検索フォームのstateに初期値として設定し、fruitsはそのまま検索結果として表示します（searchResultsのstateは不要なので削除）。
また、今回の修正の肝となるのが、handleSearchですね。
修正前はserver actionsを呼び出していますが、修正後はrouter.pushでページ遷移しています。
どこに遷移しているかというと、前述の通り自分自身のページに検索条件（name）を追加して再表示しています。
こうすることで、再度上記のpage.tsxが実行され、検索条件に合致したデータを取得して画面を再表示します。
（第２引数の{ scroll: false }に関しては以下を参照。）

:::note info
<details><summary>Next.jsのrouter.pushのscrollオプション解説(AIの解説)</summary>
- 基本的な動作
デフォルトでは、router.pushを使用してページ遷移を行うと、自動的にページの最上部にスクロールします。これは`scroll: true`がデフォルト値として設定されているためです。

## 使用方法
```javascript
const router = useRouter();

// スクロールを防止する場合
router.push('/path', { scroll: false });

// デフォルトの動作（トップへスクロール）
router.push('/path', { scroll: true });
```

## ユースケース
### スクロール位置を維持したい場合
- モーダルの表示/非表示
- フィルター適用時
- クエリパラメータの更新

### スクロールを実行したい場合
- 新しいページへの完全な遷移
- コンテンツの大幅な更新

## 技術的な詳細
このオプションは、Next.jsのApp RouterとPages Routerの両方で利用可能です。backwards/forwardsナビゲーション時には、デフォルトでスクロール位置が維持される仕組みになっています。
</details>
:::

実装は上記で以上になります。
では早速動作確認してみましょう！

- ①検索画面（フォーム未入力、検索前の状態）
![スクリーンショット 2024-12-09 20.57.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3832049/6a49e1da-e9d3-5693-b293-887724fc3300.png)

- ②検索画面（フォーム入力済、検索後の状態）
![スクリーンショット 2024-12-09 20.58.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3832049/8c8d834f-c17e-f63c-9056-beab205731cc.png)

- ③詳細画面に遷移
![スクリーンショット 2024-12-09 20.59.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3832049/4a03f96f-f85d-5f06-8a35-92017f4f9763.png)

- ④ブラウザバックで検索画面に戻る
![スクリーンショット 2024-12-09 21.00.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3832049/a33dc2cb-a971-629a-4d0e-94aeba865afe.png)

無事、④のブラウザバック時に検索結果、及びフォームの値が残るようになりましたね！
これは、検索前と検索後でページ全体が再描画されたためです。
（検索前は「〜/FruitSearch」、検索後は「〜/FruitSearch<font color="Red">?name=りんご」</font>）

というわけで、Next.jsにおけるブラウザバックで前画面の情報を残す方法でした！

# おわりに
いかがだったでしょうか。
ブラウザバックされた時にデータが消えるというのはユーザビリティ的に微妙なのでぜひ参考にしていただければ幸いです。
（とはいえ、この方法が一般的なのかどうかわからない。。。）
個人的に結構悩んだんですが、アメリカ版のメルカリのサイトが見た感じこのような動きだったので参考にしてみました（こちらのサイトもNext.jsで動いているみたいです）。
もっと良いこういうやり方があるよ！というご意見があればコメントで教えていただけると幸いです！
ここまでお読みいただきありがとうございました！

# 参考

https://www.mercari.com/

## JISOUのメンバー募集中🔥
プログラミングコーチングJISOUではメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか？
気になる方はぜひHPからライン登録お願いします！👇

https://projisou.jp/