🔗

クエリパラメータの同期を手軽に実装できる nuqs の紹介

に公開

こんにちは、steshima です。

クエリパラメータを state として扱い、簡単に URL と同期できる nuqs を紹介します。

https://nuqs.dev/

執筆時の nuqs のバージョンは 2.7.2 です。

nuqs について

nuqs は URL のクエリパラメータをフックで扱えるようにするライブラリです。
React の useState と同じ感覚でクエリパラメータを読み書きでき、URL・ブラウザ履歴・React の状態を自動で同期してくれます。

nuqs 導入前の課題

ソーシャル PLUS では、テーブルのページネーションが存在する画面ではページ番号を URL のクエリパラメータで管理しています。

nuqs を導入する以前、そのクエリパラメータの取得・同期に Next Router を直接扱う下記のようなコードが各所に散見されていました。

import { useRouter } from 'next/router';

const router = useRouter();
const pageParam = router.query.page ?? '1';
const currentPage = parseInt(pageParam as string, 10) || 1;

ページ番号を取得する際は router.query から page パラメータを手動で取り出し、文字列から数値へパースしています。
page パラメータのデフォルト値や不正な値が設定されていた場合のフォールバックなども自前で書いており、なかなかの手間です。

また、ページ変更時は Next Router の push を使い、クエリにページ番号を付与して URL を同期させていました。

nuqs での書き換え

これを nuqs を使うことで下記のようにスッキリ書けます。(パッケージインストールなどは省略)

import { useQueryState, parseAsInteger, Options } from 'nuqs';

const [page, setPage] = useQueryState(
  'page',
  parseAsInteger.withDefault(1).withOptions({ history: 'push' }),
);

useQueryState を呼び出すだけで、クエリパラメータと React の状態を自動的に同期してくれます。
React の useState みたいで非常に直感的ですね。

クエリパラメータを書き換える場合は setPage を呼び出すだけです。
また withOptionssetPage 実行時にブラウザの履歴スタックに追加されるようにしています。

複数のクエリパラメータを管理したい場合は、useQueryState に加えて useQueryStates というフックも用意されています。
以下は公式ドキュメントの例です。

import { useQueryStates, parseAsFloat } from 'nuqs'
const [coordinates, setCoordinates] = useQueryStates(
  {
    lat: parseAsFloat.withDefault(45.18),
    lng: parseAsFloat.withDefault(5.72)
  },
  {
    history: 'push'
  }
)

Built-in のパーサを活用する

nuqs には Built-in のパーサーが豊富に用意されています。

// Boolean
// ?bool=true / ?bool=false
useQueryState('bool', parseAsBoolean.withDefault(false));

// Array
// ?project=123&project=456 → [123, 456]
useQueryState('project', parseAsNativeArrayOf(parseAsInteger));

// ISO 8601 Date
// ?date=2025-11-18 → new Date('2025-11-18')
useQueryState('date', parseAsIsoDate.withDefault(new Date()));

便利だなと感じたのはリテラルのパーサーで、例えばタブを切り替える画面でクエリパラメータでタブを制御する場合、下記のように値を限定できます。

import { useQueryState, parseAsStringLiteral } from 'nuqs';

// `tab` の型は `"reserved" | "draft" | "completed" | "failed"` で推論される
const [tab] = useQueryState(
  "status",
  parseAsStringLiteral([
    'reserved',
    'draft',
    'completed',
    'failed',
  ]).withDefault("reserved"),
);

実務でのハマりどころ: Next Router × nuqs のテスト

ソーシャル PLUS では Next.js を使用していますが、テストで Next Router(Pages Router) 関連の問題がありました。

nuqs ではテスト用のアダプターが用意されており、それを使用することでモックなどせずに単体コンポーネントのテストができます。

import { withNuqsTestingAdapter } from 'nuqs/adapters/testing';

const { result } = renderHook(() => useTheHookToTest(), {
  wrapper: withNuqsTestingAdapter({
    searchParams: { count: "42" },
  }),
});

このテスト用アダプターを使えばよいかと思いきや、なぜかテストが失敗します。

テスト対象は下記画面のコンポーネントで、赤枠のタブは <a> タグのリンクになっており、タブごとにクエリパラメータだけ異なる URL に遷移する構成になっています。

テストでは Next Router のモックのために next-router-mock を使用しており、タブをクリックすると router の query は正しく更新されます。
ですがテスト用のアダプターでは router の値を参照していないため、 nuqs で管理している useQueryState などの値は変化せず、期待通りにタブが切り替わりません。

そのため通常の NuqsAdapter を使いたいところですが、そうすると今度は下記のようなエラーが発生します。

[nuqs] URL update rate-limited by the browser. Consider increasing `throttleMs` for key(s) `page`. TypeError: Cannot read properties of undefined (reading 'pathname')

下記の NuqsAdapter の実装を見てみると、内部で window オブジェクトから Next Router へアクセスしており、テスト環境ではこれが存在しないためにエラーが発生していました。

https://github.com/47ng/nuqs/blob/642cba190398544135356d5a0cade7a96213ed80/packages/nuqs/src/adapters/next/impl.pages.ts#L68

next-router-mock を使ってもそこまではモックしてくれないため、自前で設定することでこの問題を解決しています。

import mockRouter from 'next-router-mock';

// `window.next.router` に `next-router-mock` のモックルーターを設定
vi.stubGlobal('next', {
  router: mockRouter,
});

終わりに

今回の記事で紹介した nuqs の機能は基礎的な部分で、throttle や debounce など特定のケースで便利な機能も用意されているので、是非調べてみてください。

Social PLUS Tech Blog

Discussion