ReactでURLと画面状態が二重管理になる実装をレビューする観点
はじめに
検索画面や一覧画面では、URLクエリとReact stateのどちらに状態を持つかで設計が分かれる。
たとえば検索語、ページ番号、ソート順、選択中タブなどは、画面状態でありながらURLにも表現したくなる。
ここでよく起きるのが、URLとuseStateの二重管理である。
画面上は動いているように見えても、戻るボタン、共有URL、リロード、初期表示で破綻しやすい。
レビューでは「動くか」ではなく、どちらが正の状態なのかを確認する必要がある。
まず止めたい実装
次のコードは、URLクエリから初期値を読み、その後は useState で検索条件を管理している。
export function UserSearchPage() {
const searchParams = new URLSearchParams(window.location.search);
const [keyword, setKeyword] = useState(searchParams.get("q") ?? "");
const [page, setPage] = useState(Number(searchParams.get("page") ?? "1"));
useEffect(() => {
fetch(`/api/users?q=${keyword}&page=${page}`);
}, [keyword, page]);
function handleSearch(nextKeyword: string) {
setKeyword(nextKeyword);
setPage(1);
window.history.pushState(null, "", `?q=${nextKeyword}&page=1`);
}
return (
<>
<SearchBox value={keyword} onSearch={handleSearch} />
<Pagination page={page} onChange={setPage} />
</>
);
}このコードでは、検索時だけURLを更新している。
一方でページ変更時は setPage だけなので、URLと画面状態がずれる。
@Reviewer: `keyword` と `page` がURLとReact stateの両方に存在していますが、同期責務が一貫していません。戻る操作や共有URLで表示条件がずれるため、URLを正にするか内部stateを正にするか決めてください。なぜ二重管理が危ないのか
URLと画面状態がずれると、ユーザーの基本操作が壊れる。
- URLを共有しても同じ検索結果にならない
- ブラウザバックで画面条件が戻らない
- リロードすると検索条件が変わる
- ページ番号だけ古い状態のまま残る
- タブやソート順がURLと不一致になる
これはReactの書き方の問題ではなく、状態の所有者が曖昧な設計問題である。
レビューでは、検索条件のような「画面を再現する状態」はURLを正にする方が自然なことが多い。
逆に、モーダル開閉や一時的な入力途中の値は内部stateでよい場合もある。
レビュー観点1:URLで再現すべき状態か
まず、その状態をURLに置くべきかを判断する。
| 状態 | URL向きか | 理由 |
|---|---|---|
| 検索キーワード | 向いている | 共有・再現したい |
| ページ番号 | 向いている | 一覧位置を再現したい |
| ソート順 | 向いている | 結果の意味が変わる |
| 入力途中のテキスト | 場合による | 確定前なら内部stateでもよい |
| モーダル開閉 | 場合による | deep link対象ならURL、単なるUIならstate |
| hover状態 | 向かない | 一時的な表示状態 |
URL向きの状態なら、URLを正として扱う設計に寄せたい。
レビュー観点2:URLを正として読めるか
URLを正にするなら、React stateへコピーせず、URLから導出する。
export function UserSearchPage() {
const [searchParams, setSearchParams] = useSearchParams();
const keyword = searchParams.get("q") ?? "";
const page = Number(searchParams.get("page") ?? "1");
const sort = searchParams.get("sort") ?? "createdAt";
function updateQuery(next: { q?: string; page?: number; sort?: string }) {
setSearchParams({
q: next.q ?? keyword,
page: String(next.page ?? page),
sort: next.sort ?? sort,
});
}
return (
<>
<SearchBox
value={keyword}
onSearch={nextKeyword => updateQuery({ q: nextKeyword, page: 1 })}
/>
<SortSelect
value={sort}
onChange={nextSort => updateQuery({ sort: nextSort, page: 1 })}
/>
<Pagination
page={page}
onChange={nextPage => updateQuery({ page: nextPage })}
/>
</>
);
}ここでは、検索条件はURLから導出されている。
そのため、戻る操作や共有URLでも同じ状態を再現しやすい。
レビュー観点3:入力途中と検索確定を分けているか
検索ボックスでは、入力途中の文字列と確定済み検索条件を分けたいことがある。
export function SearchBox({
keyword,
onSearch,
}: {
keyword: string;
onSearch: (keyword: string) => void;
}) {
const [draft, setDraft] = useState(keyword);
useEffect(() => {
setDraft(keyword);
}, [keyword]);
return (
<form
onSubmit={event => {
event.preventDefault();
onSearch(draft);
}}
>
<input value={draft} onChange={event => setDraft(event.target.value)} />
<button type="submit">検索</button>
</form>
);
}この設計では、draft は入力途中の一時状態、keyword はURLに反映された確定条件である。
役割が明確なのでレビューしやすい。
@Reviewer: 入力途中の値と検索確定値が同じstateで扱われています。検索確定値はURL、入力途中はコンポーネント内部stateのように責務を分けると、戻る操作や再検索時の挙動が読みやすくなります。レビュー観点4:不正なURL値を補正しているか
URLはユーザーが直接編集できる。
そのため、page=-1 や sort=unknown のような値が来ても壊れない必要がある。
function readSearchCondition(searchParams: URLSearchParams) {
const page = Number(searchParams.get("page") ?? "1");
const sort = searchParams.get("sort") ?? "createdAt";
return {
keyword: searchParams.get("q") ?? "",
page: Number.isInteger(page) && page > 0 ? page : 1,
sort: ["createdAt", "name"].includes(sort) ? sort : "createdAt",
};
}レビューでは、URLから読んだ値がそのままAPIに渡されていないかを見る。
@Reviewer: URLクエリから読んだ `page` と `sort` をそのままAPIへ渡しています。URLは外部入力なので、数値範囲や許可済みsortキーへ正規化してから使ってください。レビュー観点5:replaceとpushの使い分けがあるか
検索条件更新のたびに履歴を積むと、ブラウザバックが使いづらくなる。
一方で、明示的な検索実行は履歴に残したいこともある。
レビューでは次の方針を確認する。
- 入力途中の同期は
replace - 検索実行は
push - ページ移動は
push - デフォルト値補正は
replace
履歴設計がないままURLを更新している実装は、ユーザー操作の再現性が低くなる。
レビューコメント例
@Reviewer: `useState` にURL由来の検索条件をコピーしているため、URL変更後に画面状態が追従しません。検索条件はURLを正として導出し、入力途中のdraftだけ内部stateに分けてください。@Reviewer: URLクエリは外部入力なので、`page` や `sort` をそのまま使うのは危険です。範囲外値や未知のsortキーをデフォルトへ補正する処理を入れてください。まとめ
ReactでURLと画面状態を扱うとき、レビューで見るべき中心は「状態の所有者」である。
- 共有・再現したい状態はURLを正にする
- 入力途中や一時表示は内部stateに閉じる
- URL値は外部入力として正規化する
- 戻る操作と履歴の積み方を設計する
URLとstateの二重管理は、最初は便利に見える。
しかし戻る、共有、リロードで破綻するなら、レビュー段階で所有者を決め直したほうがよい。