コンテンツにスキップ

概要

検索システムなどの特定のシステム / 機能において、画面の状態の Single Source of Truth (唯一正しい情報源) として URL を使うと、実装がシンプルになって UX が向上することがあるという話。
まとめると、「画面の描画は URL を唯一正しい情報源として、画面を更新するときは URL を変更することを通して更新しよう」です。

注意

商品検索システムを例にあげて話を進めますが、これはあくまで例です。
また、フロント側で状態をもつようなライブラリ / フレームワークを使っていることを前提とします。
サークル向けに書いた文書を元にしているので、そのサークルで利用されていた Vue を具体例であげています。

使いづらい検索画面

あなたはすでに商品検索を作り上げたとします。おめでとう、おつかれさまでした。
ところで、あなたが作成した商品検索ページで、何度か検索条件を変更したあとにページをリロードすると、現在の検索結果やページ数が変わってしまったりしませんか?
検索結果一覧の特定のページを友人に共有すると、あなたが表示している画面と友人の画面で異なる結果が表示されたりしませんか?
あるいは、ブラウザの「もどる」機能を用いて一つ前のページに戻ると意図しない挙動をしませんか?

ある実装の仕方をすると、上記のようなバグが発生してしまいます。
これは、とても使いにくく、ユーザー体験が悪いですね。

実装

これらのバグは、URL と内部状態の不一致によって発生します。
ここでいう内部状態とは、キーワードやフィルター、ページ数などの検索条件のことです。以降、このような「画面に表示するものを決定する変数の値」を「状態」と呼びます。

バグを解消するために必要なのは、状態とブラウザの URL を一致させることです。
より具体的にいうと、検索条件などの画面表示に必要な情報はすべてブラウザ上の URL に含まれており、画面を描画するときは URL から検索条件を取得するということです。

対して、よくない実装は、React や Vue のグローバル変数のようなもので状態を持ち、URL と独立に管理することです。
グローバル変数のようなものとは、Redux, Vuex や Pinia などを想定しています。(古いかもしれませんが)
あるいはグローバル変数ではなくても、useState のようなものです。
変数をライブラリ側でも独立して管理した場合のことを考えてみましょう。
このとき、ユーザーがボタンをクリックして検索条件を変更した場合、実装者は内部の状態を更新しつつ、同じように URL も更新する必要があります。
では、ユーザーが戻るボタンをおしたとき、ブラウザの URL と内部状態の同期はとられているでしょうか?
ユーザーが他のページから特定の検索条件を表す URL に遷移してきたとき、内部状態は適切に初期化されているでしょうか?
実装者が、URL と同期が必要なことを認識していないか、あるいは認識していても場合分けの考慮漏れがあって同期し忘れることにより、バグが発生する可能性が高いです。

具体例

たとえば、検索条件として、

  • キーワード
  • 商品種別
  • ページ数

があったとしましょう。
もし、どのような検索条件を指定しても/products/searchのような URL が変わらない場合、他人にページを共有すると検索の初期画面が表示されてしまいます。
これは、「ジャンル A の商品はこれだよ」みたいな共有をしたいときにとても不便です。

ではどうすればいいかというと、URL のクエリによって検索条件を指定することです。(参考:ウェブ上のリソースの識別 - HTTP | MDN

?でクエリ文字列が開始することを表し、key=valueの形でデータを表します。(たとえば、kind=book)複数のデータがあるときは、&でつなぎます。
キーワードと商品実行場所をクエリ文字列として表すと、以下のようになります。

/products/search?kind=book&keyword=computer

なお、クエリ文字列の操作には、ブラウザが提供するURLSearchParamsを用いるとよいでしょう。

ただし、これではページ数についての情報がありません。
20 ページをこえる検索結果を閲覧しているときに、途中までPCで見て、残りは移動中に見ようと思ってスマホに URL を転送すると異なるページが開かれてしまいます。
画面に表示する要素を決定するために必要な「すべての」情報を URL に保存しておくことが必要です。

Vue での実装

では具体的な実装方法の話にうつります。Nuxt などの Vue をベースにしているフレームワークにも適用できる話です。
Vue には、watchという機能があるので、それを用いるのがよいでしょう。
React では、おそらくuseEffectを使うことになるでしょう。
watchは監視するオブジェクトを指定すると、そのオブジェクトが変化したときに好きな処理を差し込むことができます。

その処理で以下のようなことをすればいいでしょう。

  1. 前と後のクエリ文字列を比較する
  2. 検索 API に必要なリクエストを行う
  3. API から返って来た情報を画面に表示するデータとして適切な変数に格納する

3 の変数が適切に Vue のリアクティブな変数として設定されていれば、変数に突っ込むだけで画面が更新されるはずです。

なお、監視する対象は、クエリ文字列を反映したオブジェクトである必要があります。
Vue では、URL 関連のライブラリとしてVue Routerがあります。それが提供するオブジェクトの一つに、RouteLocationがあり、その中に URL のパスの情報などが入っていそうです。
Nuxt の場合は、useRouteコンポーザブルが提供されていそうです。

注意点

ここで気をつけなければいけないのは、
1. ユーザーが検索ボタンを押したときの処理
2. 直接検索条件指定つきのページをユーザーが読み込んだときの処理

です。

1 は、検索ボタンを押したときの処理として検索 API にリクエストを投げることは御法度ということです。
ユーザーが検索ボタンを押したときにするべきなのは、ユーザーが指定した検索条件を反映した URL に書き換えることです。
後の処理は、watchに登録した関数がいい具合にやってくれます。ここで、watchの処理とは別に画面更新をする処理を作ってしまうと、二重管理になりバグが発生しやすくなります。
Redux の Dispatch をイメージしてもらうとわかりやすいかもしれません。

2 は、watchではページにアクセスしたときやリロードされたときは実行されないことに関わります。商品検索のページのみにwatchを登録している場合、他ページから遷移してきたときにもwatchは発火しないはずです。
これに対応するには、ページの読み込み時のみ実行される処理を書いておく必要があります。
ページ読み込み時に実行する処理を登録する関数として、ライフサイクルフックというものが提供されているので、これを使いましょう。
ライフサイクルとは、ページ(コンポーネント)が生成されてから他のページに移動するまでを「人生」と見立てて、誕生した瞬間や死ぬ直前になんらかの処理をひっかける(hook)というイメージです。onMountedなどで、クエリ文字列をパースして、検索 API に検索リクエストを投げればよいのではないでしょうか。

一般化

今回は商品検索の話を念頭に説明しましたが、URL が定まれば同じ画面が表示されるというのは UX を考えるうえで重要な観点です。
たとえば、ブラウザの戻るボタンは URL の履歴を一つもとに戻す機能なので、戻るボタンが便利であるためには URL と画面が一対一に対応している必要があります。

一方で、ログインが必要なサイトでは同じ URL でも異なる画面が表示されます。
たとえば、Amazon の注文履歴ページを友人に共有してしまった場合、友人に自分の買ったものがバレてしまっては嫌です。

あるいは、ランダムにおすすめを表示する場合も一対一対応ではないのが許されるでしょう。
さらには、モーダルの開閉状態など、一対一対応をするかどうかに議論が必要な場面もあります。
たとえば、以下のような場合です。

人数・日時を選択する空席確認カレンダーのモーダル表示がポイントです。
ここでの選択は予約にいたるまでの一連の流れのワンステップなので、操作中はブラウザの「戻る」やリロードで開いた状態を維持したいモーダルです。
ただ、その状態で URL が LINE などで共有されたときは、モーダルのない詳細ページが開いて欲しい場面でもあります。

https://user-first.ikyu.co.jp/entry/2023/12/15/093427

とはいえ、URL と画面の一対一対応が求められる場面があることを知っていること、そしてその実装方法を知っておくことは有益なはずです。

検討事項

URL を長くしすぎると、リンク共有のときにユーザーの体験が悪くなる可能性があります。
単純なところでいうと、「全選択」機能の使い方を知らないユーザーがいます。
また、オムニボックスに表示される URL は長すぎると途中で切られてしまうことがあります。(これはブラウザの実装依存なはず)
さらに、設定次第では、通信経路の途中で弾かれてしまう可能性もあります。
たとえば、Node でよく使われる URL クエリパーサーの qs (Express などで使われています)は、パースできるクエリ数にデフォルトで上限が設定されています。

parameterLimit オプションはクエリパラメータの上限数を指定する値であり、デフォルト値は 1000 です。

SECCON CTF 2022 Quals: Author writeups XS-Spin Blog

URL が長くなりそうな場合、正常に動くことをきちんと確認した方がよいでしょう。