[Vue.js] 検索条件と URL クエリパラメータを同期させる方法

# 何について書いた記事？

- 管理画面などをつくるときには、**表示項目の検索条件と URL クエリパラメータと同期させると便利だよ** という話
- **Vue.js で、検索条件の URL クエリパラメータ反映を同期** するための解説

# 「検索条件と URL を同期する」とは？

https://github.com/nuxt/nuxt.js/issues

GitHub を参考にさせてもらいましょう
まずは URL の動きに注目しながら Issue 検索の様子をご覧ください

![github-url-queries.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266244/a86029ae-337b-fb7c-b51e-81f24c86126b.gif)

GitHub の Issue 検索は以下のような挙動になっていることがわかります

1. **検索条件を選択・入力する度に URL クエリパラメータに条件が反映される**
2. 検索条件が含まれた状態で更新すると、**URL クエリパラメータの条件が反映された状態で画面が開かれる**

GitHub, AWS などの有名なサイトではたいていこのような挙動になっていますが、社内の管理画面などでは URL と検索条件を同期していない場合も多いのではないでしょうか

## もし検索条件と URL が同期されていないと...？

次のような問題が起こりそうです:

- 前の検索条件に戻したくても、ブラウザの履歴に残っていない :cry: 
- 後々のためにブックマークしておきたくても、URL として保持できない :cry: 
- 検索結果を誰かに共有したくても、いちいち「この検索条件で絞ってます」と伝えないといけない :cry: 

# Vue.js での実装

ゴールが見えたところで実際に Vue.js を使ってこのような動きを実装してみましょう！

:::note warn

実装方法はあくまで一例です
もっとスマートな書き方やベストプラクティスがあれば教えてください :pray: 

:::

## プロジェクト作成

vite の `vue-ts` テンプレートを利用してプロジェクトを作成しました

https://ja.vitejs.dev/guide/#%E6%9C%80%E5%88%9D%E3%81%AE-vite-%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%82%92%E7%94%9F%E6%88%90%E3%81%99%E3%82%8B

```sh
node -v # v16.13.1

# vue-ts テンプレートでプロジェクトを作成
npm create vite@latest vue-url-sync-sample -- --template vue-ts
```

:::note

UI コンポーネントには [QUASAR](https://quasar.dev/) を採用しました
記事の趣旨から外れるので詳細は割愛しますが、機能・ドキュメントともに充実していてかなりいい感じでした :smile:

:::

## 実装してみた

Jリーグのクラブ情報を（雑に）検索する画面を作ってみました :soccer: 

https://vue-url-sync-sample.vercel.app/

![url-sync-demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266244/4f539682-6d72-c018-10f0-5717bba657ef.gif)

**検索条件を選択する度に URL に検索条件が反映** されていて、また検索条件が含まれた状態で更新すると、**URL の条件が反映された状態で画面が開かれる** ことがわかります！

これで [神奈川県にあってスタジアムの収容人数が2万人以上のチームってどこだっけ :thinking:](https://vue-url-sync-sample.vercel.app/?prefecture=%E7%A5%9E%E5%A5%88%E5%B7%9D%E7%9C%8C&capacityMin=20000&capacityMax=73000) と聞かれてもすぐに答えることができますね :tada: 

## 「検索条件と URL を同期する」実装の解説

### 実装方針

次の方針に沿って実装を行いました

- 画面が最初に開かれたタイミング:
    - `created()` で URL に設定されている情報を data に反映して検索 API を叩く
- 検索条件が変更されたタイミング:
    - **検索 API を直接叩くのではなく、URL クエリパラメータに検索条件を追加する**
    - `onBeforeRouteUpdate()` で URL の変更を検知して、URL に設定されている情報を data に反映して検索 API を叩く

:::note

検索条件が変更された際、「URL を更新してからライフサイクルフックで変更検知」 とすることで、**「常に URL のクエリパラメータを正として検索する」** という実装にできます
これは、ロジック共通化やデバッグの容易さといったメリットにつながります

:::

### コードリーディング

それでは、Vue コンポーネントの実装を見てみましょう

https://github.com/Nag729/vue-url-sync-sample/blob/master/src/pages/ClubPage.vue

#### data 定義

`ref` で data 定義します

```typescript
// 検索条件
const name = ref("");
const prefecture = ref("");
const capacity = ref({ min: 0, max: 73000 });
```

#### data と URL クエリパラメータを相互に変換するメソッド

data と URL クエリパラメータを相互に変換するメソッドを定義します

```typescript
/* route query を ref に詰め替える */
const updateDataFromUrlQuery = (query: LocationQuery): void => {
  // data では値がないときに空文字を設定する
  name.value = queryToString(query.name) ?? "";
  prefecture.value = queryToString(query.prefecture) ?? "";
  // data では capacity をオブジェクトで保持する
  capacity.value = {
    min: queryToNumber(query.capacityMin) ?? 0,
    max: queryToNumber(query.capacityMax) ?? 73000,
  };
};

/* ref データを route.query に追加する */
const updateUrlQueryFromData = (): void => {
  router.push({
    query: {
      // URL クエリパラメータでは値がないときに undefined を設定する
      name: name.value || undefined,
      prefecture: prefecture.value || undefined,
      // URL クエリパラメータではオブジェクトではなく、min/max それぞれで保持する
      capacityMin: capacity.value?.min ?? undefined,
      capacityMax: capacity.value?.max ?? undefined,
    },
  });
};
```

#### 画面が開かれるタイミング

`created()` で URL に設定されている情報をコンポーネントの data に反映して検索 API を叩きます

```typescript
// <script setup> では created はルートに書きます

// URL クエリパラメータの情報を data に反映する
updateDataFromUrlQuery(route.query);
// 検索 API を叩く
await fetchClubData();
```

#### 検索条件が変更されたタイミング

Form コンポーネントから変更を検知したら、URL クエリパラメータに検索条件を追加します

```vue
<ClubSearchForm
  ...
  @trigger-search="updateUrlQueryFromData"
/>
```

このままだと検索 API が叩かれないので、`onBeforeRouteUpdate()` の実装を追加します

```typescript
onBeforeRouteUpdate(async (to, _, next) => {
  // URL クエリパラメータの情報を data に反映する
  updateDataFromUrlQuery(to.query);
  // 検索が完了する前に URL が更新される方が自然なので先に next() する
  next();

  // 検索 API を叩く
  await fetchClubData();
});
```

# ふりかえり

今回は URL の動きに焦点を当てて、デモサイトを作って実装を解説してみました
AWS コンソールとか、細かい検索条件を含めてほぼ全てが URL に反映されていて、簡単に情報をシェアできたりブックマークできて便利ですよね

この情報がお役に立てば幸いです :+1: