はじめに

検索画面や一覧画面では、URLクエリとReact stateのどちらに状態を持つかで設計が分かれる。
たとえば検索語、ページ番号、ソート順、選択中タブなどは、画面状態でありながらURLにも表現したくなる。

ここでよく起きるのが、URLとuseStateの二重管理である。

画面上は動いているように見えても、戻るボタン、共有URL、リロード、初期表示で破綻しやすい。
レビューでは「動くか」ではなく、どちらが正の状態なのかを確認する必要がある。

まず止めたい実装

次のコードは、URLクエリから初期値を読み、その後は useState で検索条件を管理している。

URLとstateが分離している例
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と画面状態がずれる。

Comment
@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から導出する。

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:入力途中と検索確定を分けているか

検索ボックスでは、入力途中の文字列と確定済み検索条件を分けたいことがある。

入力途中だけ内部stateに持つ例
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に反映された確定条件である。
役割が明確なのでレビューしやすい。

Comment
@Reviewer: 入力途中の値と検索確定値が同じstateで扱われています。検索確定値はURL、入力途中はコンポーネント内部stateのように責務を分けると、戻る操作や再検索時の挙動が読みやすくなります。

レビュー観点4:不正なURL値を補正しているか

URLはユーザーが直接編集できる。
そのため、page=-1sort=unknown のような値が来ても壊れない必要がある。

URL値を正規化する例
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に渡されていないかを見る。

Comment
@Reviewer: URLクエリから読んだ `page` と `sort` をそのままAPIへ渡しています。URLは外部入力なので、数値範囲や許可済みsortキーへ正規化してから使ってください。

レビュー観点5:replaceとpushの使い分けがあるか

検索条件更新のたびに履歴を積むと、ブラウザバックが使いづらくなる。
一方で、明示的な検索実行は履歴に残したいこともある。

レビューでは次の方針を確認する。

  • 入力途中の同期は replace
  • 検索実行は push
  • ページ移動は push
  • デフォルト値補正は replace

履歴設計がないままURLを更新している実装は、ユーザー操作の再現性が低くなる。

レビューコメント例

Comment
@Reviewer: `useState` にURL由来の検索条件をコピーしているため、URL変更後に画面状態が追従しません。検索条件はURLを正として導出し、入力途中のdraftだけ内部stateに分けてください。
Comment
@Reviewer: URLクエリは外部入力なので、`page` や `sort` をそのまま使うのは危険です。範囲外値や未知のsortキーをデフォルトへ補正する処理を入れてください。

まとめ

ReactでURLと画面状態を扱うとき、レビューで見るべき中心は「状態の所有者」である。

  • 共有・再現したい状態はURLを正にする
  • 入力途中や一時表示は内部stateに閉じる
  • URL値は外部入力として正規化する
  • 戻る操作と履歴の積み方を設計する

URLとstateの二重管理は、最初は便利に見える。
しかし戻る、共有、リロードで破綻するなら、レビュー段階で所有者を決め直したほうがよい。