【Next.js x React】stateとクエリパラメータを同期させる方法


Next.jsを使っていて、フォームの入力状態とクエリパラメータを同期させたいことがあったので、どのように実現したかを紹介します。

# その前に: `router.push`や`router.replace`による遷移のきほん
とあるページ上でボタンをクリックしたときに画面遷移させる場合、
```JSX
import { useRouter } from "next/router";

const Page = () => {
  const router = useRouter();

  return <button onClick={() => router.push("/path")}>click</button>; // /pathに遷移する
};

export default Page;
```
のようにルーターの`push`メソッドにパスを渡して使いますよね？

https://nextjs.org/docs/api-reference/next/router#routerpush

この`push`メソッドはパスと一緒にクエリパラメータも渡すことができます。
```JSX
import { useRouter } from "next/router";

const Page = () => {
  const router = useRouter();

  return (
    <button
      onClick={() => {
        router.push({
          pathname: "/path",
          query: { param: 1 },  // /path?param=1 に遷移する
        });
      }}
    >
      click
    </button>
  );
};

export default Page;
```
これを利用することで、同じパスでクエリパラメータだけ更新することができます。

ちなみに`push`メソッドの代わりに`replace`メソッドを使うと`history`（画面遷移の履歴）に新しく遷移先を追加するのではなく、現在の画面の履歴を置き換えられます。
例えば、[画面1] -`push`-> [画面2] -`replace`-> [画面3]と遷移してブラウザの戻るボタンを押すと画面2ではなく画面1に戻るようになります。

https://nextjs.org/docs/api-reference/next/router#routerreplace

# 本題: stateとクエリパラメータを同期させる例
フォームの選択状態とクエリパラメータを同期させる例として、プルダウンの選択状態と同期させてみます。
こちらが完成形です。
![pattern2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/425095/193aa093-f57f-3054-e4c6-6c55c3c4f1e0.gif)

## パターン1: `router.query.param`をそのまま使う場合
プルダウンの選択状態を管理するstateとして`router.query.param`をのまま使う場合は次のようなコードで実現できます。
```JSX
import { useRouter } from "next/router";

const Page = () => {
  const router = useRouter();

  return (
    <select
      value={router.query.param}
      onChange={(e) => {
        router.push({
          // 選択変更のたびにhistoryには保存する。保存したくない場合はreplaceを使う
          pathname: router.pathname,
          query: {
            param: e.currentTarget.value,
          },
        });
      }}
    >
      {["1", "2", "3"].map((n) => (
        <option value={n} key={n}>
          {n}
        </option>
      ))}
    </select>
  );
};

export default Page;
```
選択肢の`value`には`router.query.param`を直接使用していて、選択肢を切り替えるたびに`router.push`でクエリパラメータを更新しています。シンプルですね。

## パターン2: 別のstateと同期させる場合
`react-hook-form`などのライブラリを使っているとフォームの状態は別のstateで管理する必要があるかもしれません。
その場合は次のように`useEffect`を使って連動させることができます。

```JSX
import { useRouter } from "next/router";
import { useEffect, useState } from "react";

const Page = () => {
  const router = useRouter();
  const param = router.query.param;
  const [value, setValue] = useState("1"); // 選択の状態を管理する。初期状態は1を指定
  const [isInitialized, setIsInitialized] = useState(false); // 最初のページ遷移直後のクエリパラメータ反映完了フラグ

  // ページ遷移直後にクエリパラメータ -> stateの反映をする
  // router.isReadyがtrueになるのを待つことでrouter.queryに値が入ってから反映できる
  // ブラウザの戻るボタンなどでクエリパラメータが変化した場合もフォームに反映されるようにrouter.query.paramもuseEffectの依存配列に加えている
  useEffect(() => {
    if (!router.isReady) {
      return;
    }
    param && setValue(param);
    setIsInitialized(true);
  }, [router.isReady, param]);

  // 選択が切り替わったときにstate -> クエリパラメータの反映をする
  useEffect(() => {
    // 最初のページ遷移直後のクエリパラメータ反映完了前はクエリパラメータを書き換えないようにする
    if (!isInitialized) {
      return;
    }
    router.push({
      pathname: router.pathname,
      query: {
        param: value,
      },
    });
  }, [isInitialized, value]);

  return (
    <select
      value={value}
      onChange={(e) => {
        setValue(e.currentTarget.value);
      }}
    >
      {["1", "2", "3"].map((n) => (
        <option value={n} key={n}>
          {n}
        </option>
      ))}
    </select>
  );
};

export default Page;
```
パターン1に比べて若干ややこしくなりましたね。

ポイントは、最初のページ遷移直後にクエリパラメータをstateに反映する前に、フォームの初期値がクエリパラメータを上書きしてしまうのを避けるため、`isInitialized`というフラグのstateを追加している点です。
この`isInitialized`が`true`になったら初期化処理は完了したとみなし、選択を切り替えるたびに`value`をクエリパラメータに反映しています。
`isInitialized`を`true`にするタイミングは、`router.isReady`が`true`になった後にしています。
この前に初期化完了としてしまうと、`router.query.param`がまだ`undefined`の状態で`setValue`を実行してしまうため、最初のクエリパラメータをフォームにうまく反映できません。

# おまけ: Shallow Routingについて

https://nextjs.org/docs/routing/shallow-routing

```JSX
router.push(  // replaceの場合も同様
  {
    pathname: router.pathname,
    query: {
      param: newValue,
    },
  },
  undefined,
  { shallow: true }
)
```
`router.push`や`router.replace`には`shallow`というオプションが存在します。
デフォルトは`false`なのですが、これを`true`にすると遷移時にページの読み込み直しが発生せず、ルートの変更だけおこなわれます。

具体的には`getServerSideProps`、`getStaticProps`、`getInitialProps`などが呼び出されません。
これらを使っていない場合は挙動に違いは見られなかったのですが、今回のような要件であればルートの変更だけで十分なので、基本的に`shallow: true`にしておくのがよさそうです。