Reactで、URLを分解してクエリを編集できる入力欄を作る方法
URL を分解するには、ブラウザに入っている URL を使います。クエリは url.searchParams から名前と値の組で取り出せて、直した組は URLSearchParams に渡して文字列に戻せます。React の側で持つ状態は URL の文字列1つだけにして、表は描画のたびにその文字列から作ると、URL の欄と表の内容がずれません。
ここでは、URL を入れるとプロトコル・ホスト・パス・ハッシュを並べ、クエリを1行ずつ直せる UrlEditor という部品を作りました。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を esbuild で変換し、tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを Playwright で操作しました。URL と URLSearchParams だけの動きは、Node.js 22 で確かめています。Firefox と Safari、スマートフォンでの表示、日本語入力(IME)で打ったときの動きは試していません。

URL を読む部分と、クエリを書き戻す部分
先に、React に関係しない2つの関数を作ります。
// URL の文字列を分解し、クエリを [名前, 値] の行の並びにする
export type Row = [name: string, value: string];
export type Parsed =
| { ok: true; url: URL; rows: Row[] }
| { ok: false };
export function parseUrl(text: string): Parsed {
// URL.parse は、URL として読めない文字列なら例外を投げずに null を返す
const url = URL.parse(text);
if (url === null) return { ok: false };
return { ok: true, url, rows: [...url.searchParams] };
}
// 行の並びからクエリを作り直し、ほかの部分はそのままにした URL を返す
export function withRows(url: URL, rows: Row[]): string {
const next = new URL(url);
next.search = new URLSearchParams(rows).toString();
return next.href;
}
new URL() は、読めない文字列を渡すと例外を投げます。URL.parse() は同じ読み方をして、読めないときは null を返すので、入力のたびに呼ぶ部品では try / catch を書かずに済みます。Node.js で試したところ、example.com/?a=1 のように https:// のない文字列も、/search?q=1 のような相対の URL も null になりました。
クエリは get() で名前ごとに取り出すのではなく、[...url.searchParams] で全部を順番どおりに配列にしています。?tag=a&tag=b&x=1 は [["tag","a"],["tag","b"],["x","1"]] になり、同じ名前が2回あっても両方が残ります。URLSearchParams の説明にあるとおり、get() が返すのは最初の1つだけです。
書き戻すときは、元の URL を写してから search を差し替えます。ホスト、パス、ハッシュには手を付けないので、#top のようなハッシュも残ります。Node.js で https://example.com/p?q=1#top からクエリの行をすべてなくすと、toString() が空の文字列になり、? ごと消えて https://example.com/p#top になりました。
部品にまとめる
import { useId, useMemo, useState } from 'react';
import { parseUrl, withRows, type Row } from './urlQuery';
export function UrlEditor({ defaultUrl = '' }: { defaultUrl?: string }) {
const id = useId();
const [text, setText] = useState(defaultUrl);
// 状態は URL の文字列だけ。分解した結果は、描画のたびに文字列から作る
const parsed = useMemo(() => parseUrl(text), [text]);
if (!parsed.ok) {
return (
<div className="url-editor">
<label htmlFor={`${id}-url`}>URL</label>
<input id={`${id}-url`} value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} />
<p role="alert">URL として読み取れません。https:// などから書いてください</p>
</div>
);
}
const { url, rows } = parsed;
const update = (next: Row[]) => setText(withRows(url, next));
const change = (i: number, col: 0 | 1, v: string) =>
update(rows.map((r, j): Row => (j !== i ? r : col === 0 ? [v, r[1]] : [r[0], v])));
return (
<div className="url-editor">
<label htmlFor={`${id}-url`}>URL</label>
<input id={`${id}-url`} value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} />
<dl>
<dt>プロトコル</dt><dd>{url.protocol}</dd>
<dt>ホスト</dt><dd>{url.host}</dd>
<dt>パス</dt><dd>{url.pathname}</dd>
<dt>ハッシュ</dt><dd>{url.hash || '(なし)'}</dd>
</dl>
<table>
<caption>クエリ</caption>
<thead>
<tr><th scope="col">名前</th><th scope="col">値</th><th /></tr>
</thead>
<tbody>
{rows.map(([name, value], i) => (
// 行は自分の状態を持たないので、並び順の番号を key にしている
<tr key={i}>
<td><input aria-label={`${i + 1}行目の名前`} value={name} onChange={(e) => change(i, 0, e.target.value)} /></td>
<td><input aria-label={`${i + 1}行目の値`} value={value} onChange={(e) => change(i, 1, e.target.value)} /></td>
<td>
<button type="button" aria-label={`${i + 1}行目を消す`} onClick={() => update(rows.filter((_, j) => j !== i))}>
×
</button>
</td>
</tr>
))}
</tbody>
</table>
<button type="button" onClick={() => update([...rows, ['', '']])}>行を足す</button>
</div>
);
}
.url-editor {
display: grid;
gap: 6px;
max-width: 600px;
}
.url-editor input {
font: 1rem ui-monospace, monospace;
padding: 6px 8px;
min-width: 0;
}
.url-editor dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 2px 12px;
margin: 4px 0;
}
.url-editor dd {
margin: 0;
font-family: ui-monospace, monospace;
overflow-wrap: anywhere;
}
.url-editor table {
border-collapse: collapse;
width: 100%;
}
.url-editor caption {
text-align: left;
font-weight: bold;
}
.url-editor td input {
width: 100%;
box-sizing: border-box;
}
.url-editor [role='alert'] {
color: #d85878;
}
表の行を state に持たない
URL の欄と表の両方を編集できるようにすると、URL の文字列と行の配列を別々の state に持ち、片方が変わったらもう片方を useEffect で合わせたくなります。この部品では、state は text だけにしました。表の行は parseUrl(text) から毎回作り、表を直したときは withRows() で作った URL を setText() に渡します。
どちらの欄を直しても、変わるのは text だけです。そのため、2つの欄の内容が食い違うことがありません。React の公式ドキュメントにある state の構造の選び方でも、props や他の state から計算できる情報は state に入れないこと、同じデータを複数の state に重複して持たないことが勧められています。
最初の表示から、2行目の値を 3 にし、「行を足す」を押して名前に sort、値に new を打つと、URL の欄は次のように変わりました。
https://example.com/search?q=cat&page=2#results
https://example.com/search?q=cat&page=3#results
https://example.com/search?q=cat&page=3&=#results
https://example.com/search?q=cat&page=3&sort=new#results
足したばかりの行は名前も値も空なので、URL には &= が付きます。名前を打てば &sort= に変わります。行を全部消すと、https://example.com/search#results になりました。
打っている途中でも、カーソルと入力欄は動かない
表の値の欄に打つたびに、URL の文字列が作り直され、そこから行がもう一度作られます。それでも、値の欄の中身は打った文字と同じになるので、カーソルは動きません。ab の a の後ろに「 x」を打つと、値は a xb になりました。カーソルは打った位置(3文字目の後ろ)にあり、末尾へ飛びませんでした。
URL の欄は、読めない文字列のあいだは表を出さず、注意の文だけを出します。example.com/?q=1 と打つと「URL として読み取れません。https:// などから書いてください」が出て、https:// を足すと表に戻りました。読めるときと読めないときで、div の中の label と input の並びを同じにしています。React の公式ドキュメントの state の保持とリセットにあるとおり、React は同じ位置にある同じ種類の要素を作り直さずに使い続けます。空の欄から https://example.com/?q=猫&tag=a+b&tag=c%20d を1文字ずつ打つと、途中で読めない状態を通っても最後まで打ち込めて、打ち終わったときもフォーカスは URL の欄にありました。
表の値は、デコードした文字で見える
searchParams から取り出した名前と値は、パーセントエンコードを戻した文字になっています。上の URL を打ったときの表は、q が 猫、tag が a b と c d でした。クエリの中の + は、空白として読まれます。+ という文字そのものを入れたいときは、URL に %2B と書きます(?q=a%2Bb の値は a+b)。
表に & や # を打っても、クエリの区切りやハッシュの始まりにはなりません。前の節の a xb の欄で、カーソルの位置に「&#」を打つと、URL は ?q=a+x%26%23b になり、表の値は a x&#b のままでした。
表を直すと、URL の書き方がそろえられる
表を1か所でも直すと、URL の欄の文字列は href が返す形に置き換わります。直していない部分の書き方も変わります。withRows() と同じ書き戻し方を Node.js で試したところ、次のように変わりました。
| URL の欄に入っていた書き方 | 表を直したあとの書き方 |
|---|---|
?q=a%20b(空白を %20) |
?q=a+b |
?q=猫 |
?q=%E7%8C%AB |
?flag&x=(= のない名前) |
?flag=&x= |
?a=1&&b=2&(空の区切り) |
?a=1&b=2 |
HTTPS://Example.COM/a/../b |
https://example.com/b |
ブラウザの部品でも、HTTPS://Example.COM/a/../b?flag&x=1 を開いた時点では欄にそのまま残り、表で x の値を 2 に直したところで https://example.com/b?flag=&x=2 に変わりました。
+ と %20 は、どちらも空白として読まれたあと、書き戻すときに + になります。URLSearchParams の toString() は、URL Standard の application/x-www-form-urlencoded の書き出し方に従っていて、空白を + にします。new URLSearchParams() で作り直さずに、url.searchParams.set('x', '2') で1つの値だけを変えた場合も、Node.js では、触っていない q=a%20b が q=a+b に変わりました。
一方、URL の欄に手で打っているあいだは、打った文字列をそのまま state に入れるので、書き方は変わりません。?q=猫 と打てば、表を触るまでは欄に 猫 のまま残ります。表で q の値を 犬 に直すと、欄は ?q=%E7%8A%AC&tag=a+b&tag=c+d になり、触っていない tag=c%20d も tag=c+d になりました。
encodeURIComponent とは、エンコードする文字が違う
クエリを自分で組み立てるときに使われる encodeURIComponent と、URLSearchParams では、エンコードする文字が違います。空白、~、*、'、(、) の6文字を、q という名前の値として URLSearchParams で書き出した場合と、値だけを encodeURIComponent に渡した場合を Node.js で比べました。
同じ値の encodeURIComponent "%20~*'()"
URLSearchParams で ~*'() "q=+%7E*%27%28%29"
URLSearchParams は名前ごと書き出すので、下の行には q= が付いています。
URLSearchParams は空白を + にし、~
'
(
) もエンコードします。encodeURIComponent は空白を %20 にし、~
'
(
) はそのまま残します。Node.js では、どちらで書いたものも searchParams で読むと同じ値に戻りました。この部品のように、読むのも書くのも URLSearchParams にしておけば、2つの書き方を混ぜずに済みます。





