URLパラメータをデータリソースとして扱う設計パターン - 自作hooksからnuqsへ

開発

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 + URLURLのみ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もデータリソース」という視点で設計を見直してみてください。

  1. 二重管理をやめる: ローカルstate + URLの両方を持たない。URLを唯一の情報源に
  2. 責務を分離する: 取得・保存・同期・マッピングを分けて考える
  3. Repositoryパターン: コンポーネントはURLの存在を意識しない
  4. nuqsを使う: 自作の基盤hooksが不要に。Repository層は薄く残す

僕は基盤hooksを自作してRepository層を被せていましたが、nuqsなら基盤部分が不要になり、Repository層も薄くシンプルになりました。設計思想は正しかった。ツールが基盤部分を肩代わりしてくれるようになっただけです。

npm install nuqs

useEffectでURLとstateを同期しているコードがあったら、まず設計から見直してみてください。二重管理をやめるだけで、かなりシンプルになるはずです。

Thanks for reading!
ReactNext.jsnuqsTypeScript設計パターンURL状態管理