URLのクエリパラメータを扱うコード、気づいたら100行超えてませんか?
useSearchParamsでゴリゴリ書いていくと、serialize/deserialize、型変換、同期処理、更新ロジック…全部が1つのhooksに詰め込まれて、もう何がなんだか分からなくなる。
僕はこの問題に対して「URLもデータリソースとして扱う」という設計で対処してきました。今回は、その考え方と、最終的にnuqsに移行した話をシェアします。
URLパラメータ管理、なぜ複雑になるのか
よく見かけるのがこういうコード。
// よくある問題パターン
export function useSearchFilters() {
const router = useRouter();
const searchParams = useSearchParams();
// URLから初期値を取得し、ローカルステートで管理
const [categories, setCategories] = useState<string[]>(() =>
parseCategories(searchParams),
);
// URLが変更された場合にローカルステートを同期
useEffect(() => {
setCategories(parseCategories(searchParams));
}, [searchParams]);
const toggleCategory = useCallback((category: string, checked: boolean) => {
setCategories((prev) =>
checked ? [...prev, category] : prev.filter((c) => c !== category)
);
}, []);
const applyToUrl = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
if (categories.length > 0) {
params.set('category', categories.join(','));
} else {
params.delete('category');
}
if (params.has('page')) params.set('page', '1');
router.push(`?${params.toString()}`);
}, [router, searchParams, categories]);
// ...さらに続く
}
何が問題か分かりますか?
- 二重管理: ローカルstate + URLパラメータの両方を管理している
- useEffectで同期: URLが変わったらstateを更新…これ、書きたくないですよね
- 責務の混在: parse、serialize、同期、UI更新が全部1つのhooksに
特にuseEffectでの同期は最悪です。「URLが変わったらstateを更新」「stateが変わったらURLを更新」…無限ループの匂いがするし、そもそも二重管理する必要がない。
この問題、海外でも共通認識になっています。TanStack Routerのディスカッションでは、URL状態を4つに分類して整理しています。
| 分類 | 例 | 特徴 |
|---|---|---|
| パスパラメータ | /users/123 | リソースの識別 |
| 検索パラメータ | ?page=2&sort=desc | フィルタリング・設定 |
| UI状態 | ?modal=open | 表示状態 |
| 入力状態 | ?draft=... | フォーム入力の保存 |
問題は、これらを1つのhooksで全部管理しようとすること。責務が混在して、すぐに破綻します。
僕の解決策:URLをリソースとして抽象化する
URLもAPIと同じ「データリソース」として考えるとスッキリします。
- 取得(parse / deserialize)
- 保存(serialize)
- 同期(subscribe)
- マッピング(URLキー ↔ アプリケーション値)
これ、よく考えるとRepositoryパターンそのものなんですよね。
- コンポーネント: 表示とユーザー操作の処理(UIの責務)
- Repository: データの取得・保存(リソースの責務)
APIからデータを取得するときと同じように、URLからも「条件を取得」「条件を保存」するだけ。この考え方で整理すると、責務がスッキリ分かれます。
実際に作ったRepository
正直、ここまでやるのは多くのフロントエンドでは過剰です。ただ、僕の場合はモバイル(React Native)とWebでロジックを共通化する必要があった。だからここまで抽象化しました。
// ListConditionsRepository.ts
type SearchParams = {
tab: 'active' | 'archive';
page: number;
search: string;
};
export const useListConditionsRepository = () => {
const { normalized, merge } = useTypedSearchParams<SearchParams>();
const tab = normalized?.tab ?? 'active';
const page = Number(normalized?.page ?? 1);
const search = normalized?.search ?? '';
return {
get: () => ({ page, pageSize: 20, search, tab }),
save: async (params) => merge(params),
};
};
使う側はこうなります。
// コンポーネント側
const conditionsRepository = useListConditionsRepository();
const { page, tab, search } = conditionsRepository.get();
// ページ変更
const onPressPage = (newPage: number) => {
conditionsRepository.save({ page: newPage });
};
// 検索
const onChangeSearch = (value: string) => {
conditionsRepository.save({ page: 1, search: value || null });
};
コンポーネントはURLの存在を知らない。「条件を取得」「条件を保存」するだけ。
モバイルではuseTypedSearchParamsの代わりにAsyncStorageを使うRepositoryに差し替えれば、コンポーネント側のコードは一切変更なしで動きます。この共通化が必要だったからこそ、ここまで抽象化した意味がありました。
海外の記事でも「Your URL Is Your State」という考え方が広まっていて、判断基準として「他者がこのURLをクリックして、同じ状態を見るべきか?」が挙げられています。この基準で考えると、何をURLに入れるべきかが明確になります。
基盤として作った useTypedSearchParams
nuqsの存在を知る前、型安全にURL状態を扱うために基盤となるhooksを自作していました。
export const useTypedSearchParams = <
TSearchParams extends { [key: string]: any },
TSchema extends { [key: string]: any } = TSearchParams,
>({
transform: {
serialize = defaultSerializer,
normalize = defaultNormalizer,
} = {},
}: Options<TSearchParams, TSchema> = {}) => {
const [searchParams, setSearchParams] = useSearchParams();
const merge = useCallback(
(obj: PartialNullable<TSchema>) => {
const serialized = serialize(obj as Partial<TSchema>);
Object.entries(serialized).forEach(([key, value]) => {
if (value === null) {
searchParams.delete(key);
} else if (value) {
searchParams.set(key, value);
}
});
setSearchParams(searchParams, { preventScrollReset: true });
},
[serialize, searchParams, setSearchParams],
);
// append, remove, clear, reset なども提供
return { reset, append, clear, remove, merge, normalized };
};
これで型安全にURL状態を扱えるようになりました。
- serialize/normalize: URLと値の型変換を担当
- merge: 部分更新を簡単に
- normalized: 変換済みの型付き値を取得
ローカルstateとの二重管理も不要。URLが唯一の情報源(Single Source of Truth)になります。
nuqsなら基盤hooksすら不要
useTypedSearchParamsで型安全にURL状態を扱えるようになった。でも、これを自前で作ってメンテするのは正直しんどい。
そこでnuqs。React Advanced 2025でnuqs作者のFrançois Bestが発表した内容によると、GitHub Star数は1万近く、Sentry、Supabase、Vercel、Clerkなどが採用しています。
nuqsを使うと、自作の基盤hooks(serialize/deserialize、型変換)が不要になります。
import { parseAsString, parseAsInteger, parseAsStringEnum, useQueryStates } from 'nuqs';
const searchParamsSchema = {
tab: parseAsStringEnum(['active', 'archive']).withDefault('active'),
page: parseAsInteger.withDefault(1),
search: parseAsString.withDefault(''),
};
export const useListConditions = () => {
const [params, setParams] = useQueryStates(searchParamsSchema);
return {
...params,
updatePage: (page: number) => setParams({ page }),
updateSearch: (search: string) => setParams({ page: 1, search }),
updateTab: (tab: 'active' | 'archive') => setParams({ tab }),
};
};
100行 → 20行。型安全なURL状態管理が簡単に実現できます。
nuqsでもRepository層は価値がある
ただし、nuqsを使えばRepository層が不要になるわけではありません。nuqsが解決するのは基盤部分(型変換・同期)であり、抽象化層としてのRepositoryは別の価値を持ちます。
// ListConditionsRepository.ts - nuqs版
import { parseAsString, parseAsInteger, parseAsStringEnum, useQueryStates } from 'nuqs';
const searchParamsSchema = {
tab: parseAsStringEnum(['active', 'archive']).withDefault('active'),
page: parseAsInteger.withDefault(1),
search: parseAsString.withDefault(''),
};
export const useListConditionsRepository = () => {
const [params, setParams] = useQueryStates(searchParamsSchema);
return {
get: () => ({ ...params, pageSize: 20 }),
save: async (updates: Partial<typeof params>) => setParams(updates),
};
};
コンポーネント側のコードは自作hooks版とまったく同じになります。
// コンポーネント側(変更なし)
const conditionsRepository = useListConditionsRepository();
const { page, tab, search } = conditionsRepository.get();
const onPressPage = (newPage: number) => {
conditionsRepository.save({ page: newPage });
};
Repository層を残すメリット:
- プラットフォーム間の共通化: モバイルではAsyncStorage版に差し替え可能
- テスタビリティ: Repositoryをモック化してコンポーネントを単体テスト
- URLの存在を隠蔽: コンポーネントは「条件の取得・保存」だけを意識
各アプローチの比較
| 課題 | よくある実装 | 自作基盤 | nuqs |
|---|---|---|---|
| 二重管理 | state + URL | URLのみ | URLのみ |
| serialize/deserialize | 手動 | transform指定 | parseAsXxx |
| 型安全 | なし | 自分で定義 | スキーマから推論 |
| デフォルト値 | 条件分岐 | normalize内 | .withDefault() |
| URLキーのマッピング | なし | なし | urlKeys |
| パフォーマンス | 考慮なし | 考慮なし | 50msスロットリング |
| Repository層 | 必要なら別途 | 別途作成 | 別途作成(薄くできる) |
特にURLキーのマッピングは便利です。内部では分かりやすい変数名、URLは簡潔に保てます。
const params = useQueryStates(
{ searchQuery: parseAsString },
{ urlKeys: { searchQuery: 'q' } } // URL上は ?q=xxx
);
nuqsの設計思想
nuqs作者は「teleportation」と「time travel」という2つの概念でURL状態管理の価値を説明しています。
- Teleportation: リンクを共有するだけで、完全なアプリケーション状態を再現できる
- Time Travel: ブラウザの戻る・進むボタンで過去の状態を呼び出せる
これ、まさに僕がRepositoryパターンで実現したかったことなんですよね。URLを「永続化されたアプリケーション状態」として扱う。
nuqsを知った時、「基盤部分は全部これでいいじゃん」と感動しました。自作の経験があったからこそ、nuqsの設計の良さがよく分かります。Repository層を被せるかどうかは、プロジェクトの要件次第です。
URLに何を入れるべきか
海外記事「Your URL Is Your State」では、明確な判断基準が示されています。
URLに入れるべき状態
- 検索クエリとフィルター
- ページネーション・ソート
- ビューモード(リスト/グリッド表示)
- タブ選択状態
- 日付範囲
URLに入れるべきでない状態
- 機密情報(パスワード、トークン)
- 一時的なUI状態(モーダルの開閉)
- 保存前のフォーム入力
- 大規模なネストされたデータ
判断軸は「他者がこのURLをクリックして、同じ状態を見るべきか?」です。
まとめ
URLパラメータの管理が複雑になったら、「URLもデータリソース」という視点で設計を見直してみてください。
- 二重管理をやめる: ローカルstate + URLの両方を持たない。URLを唯一の情報源に
- 責務を分離する: 取得・保存・同期・マッピングを分けて考える
- Repositoryパターン: コンポーネントはURLの存在を意識しない
- nuqsを使う: 自作の基盤hooksが不要に。Repository層は薄く残す
僕は基盤hooksを自作してRepository層を被せていましたが、nuqsなら基盤部分が不要になり、Repository層も薄くシンプルになりました。設計思想は正しかった。ツールが基盤部分を肩代わりしてくれるようになっただけです。
npm install nuqs
useEffectでURLとstateを同期しているコードがあったら、まず設計から見直してみてください。二重管理をやめるだけで、かなりシンプルになるはずです。