アプリの状態を URL だけで持たせる

URL を状態の置き場所にした理由

身長比較画像メーカーとコード進行伴奏プレイヤーは、状態をすべて URL に持たせている。
サーバーもデータベースもログインもない。
アドレスバーをコピーすれば、いま見ている状態をそのまま人に渡せるし、ブックマークしておけばリロードしても同じ状態が表示される。

小さなクライアントサイドのツールにとって、URL は想像以上に実用的な状態の置き場所である。
最初から共有できてブックマークもできるため、「共有機能」をわざわざ別に作らずに済む。
リロードしても、「戻る」「進む」を押しても状態が保たれる。
ホスティング費用がかからず、サーバーが落ちることもない。
中身がユーザーから見えて直接編集できるのも、隠れた状態がないという意味では素直な設計である。

一方で、そこに入れたものはすべて公開され、扱える文字もほぼ ASCII の範囲内に限られ、長さにも上限がある。
この制約は最後にまとめる。

履歴を汚さないために replaceState を使う

状態はほぼ1文字打つたびに変化する。
その都度 history.pushState していたら、少しいじっただけでユーザーの「戻る」ボタンが何十回分もの履歴の向こうに埋もれることになる。
そのため、現在の履歴エントリをその場で書き換える history.replaceState を使う。

history.replaceState(null, "", "?" + params.toString());

両方のツールとも、入力イベントごとにこれを呼び出している。
ツールページの履歴は常に1段だけで、アドレスバーはいつでも最新の状態を指す。

値がいくつかなら URLSearchParams で十分

コード進行伴奏プレイヤーが持っているのは、独立した値が5つ(コード進行・テンポ・拍子・パターン・音量)だけである。
そのため URLSearchParams をそのまま使っており、特別な処理は必要ない。

// 書き込み
const q = new URLSearchParams();
q.set("c", chords.trim());
q.set("t", String(tempo));
q.set("m", meter);
history.replaceState(null, "", location.pathname + "?" + q.toString());

// 読み込み
const q = new URLSearchParams(location.search);
const chords = q.has("c") ? q.get("c") : DEFAULT_CHORDS;

コード進行のテキストには空白や |、#、改行が入るが、そのパーセントエンコードは URLSearchParams が処理する。
状態がいくつかの名前付きの値だけで足りるなら、これで十分完結する。

人物リストを1つのパラメータに詰める

身長比較画像メーカーは事情が異なる。
持っているのは人物が並んだリストで、それぞれがユーザーの打ち込んだ名前・身長・色を持つ。
これはフラットな key/value には収まらないため、リスト全体を独自の書式で1つのパラメータにまとめている。

?people=Alice:170:7c9eff,Bob:160:ffb86b

カンマで人物を区切り、コロンで各フィールドを区切る。
ただし、フィールドにユーザーが入力した任意のテキストが入るようになると、区切り文字と文字が衝突する問題が生じる。
名前が A, B だったらどうなるか。10:30 だったらどうなるか。

対策として、書き出すときは名前だけあらかじめパーセントエンコードしておき、読み込むときはデコードする前の生のクエリ文字列をそのまま , と : で分割する。

// 書き込み: 各名前をエンコードしてから、区切り文字でつなぐ
const value = people
  .map((p) => `${encodeURIComponent(p.name)}:${p.height}:${p.color}`)
  .join(",");
history.replaceState(null, "", value ? `?people=${value}` : location.pathname);

// 読み込み: 生の値を取り出して分割し、そのあとで名前だけデコードする
const raw = location.search.match(/[?&]people=([^&]*)/);
const people = (raw ? raw[1].split(",") : [])
  .map((entry) => {
    const [name, height, color] = entry.split(":");
    const h = parseFloat(height);
    if (!name || !isFinite(h) || h <= 0) return null;
    return { name: decodeURIComponent(name), height: h, color: normalizeColor(color) };
  })
  .filter(Boolean);

名前に含まれるカンマやコロンは、URL 上では %2C や %3A に変換される。
そのため素の文字で分割しても、区切り位置を読み違えることはない。
デコードは分割が終わったあと、フィールドごとに1回だけ行う。

このコードがあえて行っていないのは、この値を URLSearchParams に通すことである。
通すと % が二重にエンコードされ、次に読み書きしたときに %252C になる。
独自のエンコードと URLSearchParams は併用できないため、パラメータごとにどちらを使うか明確に決める必要がある。

URL は信頼せず、読むたびに検証する

URL を通じてアクセスされるデータは、いつか必ず「途中で途切れたもの」「手動で書き換えられたもの」「何年も前の古い形式のもの」に遭遇する。
そのため、読み込むたびにすべてのフィールドを検証し、例外を投げるのではなく、安全な状態にフォールバックさせる。

  • 数値はパースして isFinite を確認し、妥当な範囲に丸める。コード進行伴奏プレイヤーの場合は、テンポを 30〜300 の範囲に収め、不正な数値であれば 90 に戻す。
  • 選択肢はホワイトリストと照合する。拍子やパターンはマップを参照し、一致するものがない場合は既定値にする。
  • リストは要素ごとに検証し、不正なものだけを捨てる(上の .filter(Boolean))。有効な要素が1件も残らない場合、空っぽのツールを表示する代わりに、組み込みの初期セットに戻す。

どんなに壊れた URL でアクセスされても画面がクラッシュせず、最悪でも初期状態に戻るというのが、この設計の狙いである。

機能を削っても古いリンクを壊さない

コード進行伴奏プレイヤーにはかつて、楽器を選ぶ機能と、そのための i パラメータが存在した。
この機能を廃止した際に行ったのは、読み込み側で i を参照する処理を消すことだけである。
&i=2 が付いたままの古い共有リンクであっても、そのまま正常に動作する。
知らないパラメータは単に無視され、残りの設定が通常どおり読み込まれる。

これは URL に状態を持たせることの大きなメリットであり、読む側と書く側のライフサイクルを時間的に切り離せる。
「パラメータは足すだけ」「知らないものは無視するだけ」という原則を守っている限り、過去に配ったリンクはすべてそのまま機能し続ける。

URL に持たせないほうがいいもの

  • 長さ。ブラウザ、サーバー、あるいはリンクをリッチプレビューとして展開するチャットアプリなどの制限を考えると、URL 全体で 2000 文字程度に収めるのが無難である。人物10人分や数小節のコード進行程度なら問題ないが、長文のドキュメント全体を収めるのには向かない。
  • 機密情報。URL はブラウザの履歴、サーバーのアクセスログ、Referer ヘッダ、アクセス解析などに残るため、人に見られて困る情報は一切含めてはならない。
  • 大容量データやバイナリ。LZString や Base64 の巨大な塊に手が伸びそうになったら、編集中のコピーは localStorage に保持し、URL はあくまで共有専用と割り切る方法を検討するのがよい。

状態が「数人分の人物データ」や「コード進行といくつかのつまみ」といった規模に収まるツールであれば、URL こそが必要十分なバックエンドの役割を果たす。