はじめに
Reactで検索フィルターやページネーションを実装するとき、URLのクエリパラメータと状態を同期させたいことってありますよね。
「この検索結果のURLを共有したい」とか「ブラウザバックで前の状態に戻りたい」とか、ユーザー目線で考えると当然の要望なんですが、これを自前で実装しようとすると意外と面倒なんですよ。
そこで今回紹介したいのが Nuqs というライブラリ。これ、URLのクエリパラメータをまるでuseStateのように扱えるようになるんです。個人的にはかなり感動したので、詳しく紹介していきます。
とはいえ、説明を読むより実際に触ってみたほうが早いと思います。下のサンプル節では、ボタンを押すとURLクエリパラメータがリアルタイムに書き換わるカウンターや、複数条件を組み合わせた商品フィルターなどをその場で動かせます。先に挙動を見たい方はこちらからどうぞ。
Nuqsとは
Nuqsは「Type-safe search params state manager for React」というキャッチフレーズの通り、URLのクエリパラメータを型安全に状態管理できるライブラリです。
- GitHub Stars: 9,500以上
- 月間ダウンロード数: 400万以上
- バンドルサイズ: 約6kB(gzip)
正直なところ、このスター数とダウンロード数を見て「なんで今まで知らなかったんだ」と思いました。
特徴・メリット
useStateライクなAPI
一番の特徴は、useStateとほぼ同じ感覚で使えること。学習コストがほぼゼロなんですよね。
// 普通のuseState
const [count, setCount] = useState(0)
// Nuqsの場合
const [count, setCount] = useQueryState('count', parseAsInteger.withDefault(0))
違いは第一引数にクエリパラメータ名を指定することと、パーサーを渡すことくらい。これでURLの?count=5みたいなパラメータと自動で同期してくれます。
型安全
TypeScriptとの相性が抜群です。パーサーを指定することで、stringだけじゃなくnumberやboolean、配列なども型安全に扱えます。
import { parseAsInteger, parseAsBoolean, parseAsArrayOf } from 'nuqs'
// 数値として扱う
const [page, setPage] = useQueryState('page', parseAsInteger)
// 真偽値として扱う
const [darkMode, setDarkMode] = useQueryState('dark', parseAsBoolean)
// 配列として扱う
const [selectedIds, setSelectedIds] = useQueryState(
'ids',
parseAsArrayOf(parseAsInteger)
)
これ、意外とありがたいんですよ。クエリパラメータって基本的に文字列なので、毎回パースするコードを書くのが地味に面倒だったんです。
マルチフレームワーク対応
Next.js(App Router / Pages Router)はもちろん、Remix、React Router、TanStack Routerなど主要なフレームワークに対応しています。プロジェクトを跨いでも同じ書き方ができるのは嬉しいですね。
ブラウザ履歴との連携
デフォルトではhistory.replaceState(URLを置き換え)ですが、history.pushState(履歴に追加)に切り替えることもできます。つまり、ブラウザの戻るボタンで前の状態に戻れるようになります。
インストール方法
お好みのパッケージマネージャーでインストールできます。
npm install nuqs
# または
yarn add nuqs
# または
pnpm add nuqs
nuqsのサンプルを動かす
nuqsのuseQueryStateとparseAsIntegerを使うと、普段のuseStateと同じ書き味のまま、URLクエリパラメータをReactの状態として扱えます。下のサンプルは、ボタンでカウントを増減させると、それに連動してURLの?count=パラメータが変わるという最小構成の例です。
このLiveCodesの実行環境ではNext.jsのルーターが使えないため、Next.js非依存のnuqs/adapters/reactという素のReact向けアダプタを使っています。これはブラウザのHistory APIを直接操作するアダプタで、ルートをNuqsAdapterでラップするだけで、Next.jsアプリと同じuseQueryState・useQueryStatesがそのまま動きます。
まず要点だけを抜き出すと、次のようになります。
import { NuqsAdapter } from 'nuqs/adapters/react'
import { useQueryState, parseAsInteger } from 'nuqs'
function Counter() {
const [count, setCount] = useQueryState(
'count',
parseAsInteger.withDefault(0)
)
return (
<div>
<button onClick={() => setCount((c) => c - 1)}>-</button>
<span>{count}</span>
<button onClick={() => setCount((c) => c + 1)}>+</button>
</div>
)
}
実際に動かせるのが下のサンプルです。ボタンを押してカウントを変えると、画面内に表示している「現在のURL」の?count=部分がリアルタイムに書き換わるのが分かります。
「リセット」ボタンではsetCount(null)を呼んでいます。nuqsではnullを渡すとデフォルト値(ここでは0)に戻り、URLからもcountパラメータそのものが消えるのがポイントです。parseAsIntegerをparseAsStringに差し替えれば、数値ではなく文字列としてそのままクエリに保存されるようになります。
基本的な使い方
Next.js App Routerでの設定
まず、ルートレイアウトでNuqsAdapterをラップします。
// app/layout.tsx
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import { type ReactNode } from 'react'
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html>
<body>
<NuqsAdapter>{children}</NuqsAdapter>
</body>
</html>
)
}
シンプルな例
検索ボックスの値をURLに同期させる例です。
'use client'
import { useQueryState } from 'nuqs'
export default function SearchBox() {
const [query, setQuery] = useQueryState('q', { defaultValue: '' })
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value || null)}
placeholder="検索..."
/>
)
}
入力すると自動的にURLが?q=検索ワードのように変わります。ページをリロードしても状態が復元されるし、このURLを共有すれば同じ検索結果を見せられます。
複数のパラメータを扱う
useQueryStatesを使えば、複数のパラメータをまとめて管理できます。
'use client'
import { useQueryStates, parseAsInteger, parseAsString } from 'nuqs'
export default function ProductFilter() {
const [filters, setFilters] = useQueryStates({
category: parseAsString.withDefault('all'),
minPrice: parseAsInteger.withDefault(0),
maxPrice: parseAsInteger.withDefault(100000),
page: parseAsInteger.withDefault(1),
})
return (
<div>
<select
value={filters.category}
onChange={(e) => setFilters({ category: e.target.value })}
>
<option value="all">すべて</option>
<option value="electronics">家電</option>
<option value="clothing">衣類</option>
</select>
<input
type="number"
value={filters.minPrice}
onChange={(e) => setFilters({ minPrice: Number(e.target.value) })}
/>
<button onClick={() => setFilters({ page: filters.page + 1 })}>
次のページ
</button>
</div>
)
}
実践的なユースケース
ECサイトの商品一覧フィルター
個人的に一番使いたくなるのがこのパターン。カテゴリ、価格帯、ソート順、ページネーションなど、複数の条件を組み合わせた検索結果をURLで共有できるようになります。
「この条件で探してる商品、見てみて」ってURLを送れるのは、ユーザー体験としてかなり良いですよね。
nuqsのuseQueryStatesとparseAsStringEnum・parseAsIntegerを組み合わせると、こうした複数条件のフィルターをまとめて1つのオブジェクトとして管理できます。
import { useQueryStates, parseAsStringEnum, parseAsInteger } from 'nuqs'
function useProductFilters() {
return useQueryStates({
category: parseAsStringEnum(['all', 'electronics', 'clothing']).withDefault('all'),
sort: parseAsStringEnum(['new', 'price-asc', 'price-desc']).withDefault('new'),
page: parseAsInteger.withDefault(1),
})
}
実際に動かせるのが下のサンプルです。カテゴリやソート順を切り替えたり「次のページ」を押したりすると、画面上部に表示しているクエリ文字列がその場で書き換わります。
カテゴリを変えたときにpageも1にリセットしているのがポイントです。setFiltersに渡したキーだけがまとめて更新されるので、「フィルター条件を変えたらページ番号は先頭に戻す」といった調整も1回の呼び出しで済みます。categoriesの配列に'books'のような値を追加すれば、選択肢はそのまま増やせます。
ダッシュボードの表示設定
期間選択やグラフの表示モードなど、ダッシュボードの設定をURLに保持しておくと便利です。チームでデータを共有するときに「この期間のこのグラフ見て」ってURLを送るだけで済みます。
nuqsではparseAsStringEnumで期間のような選択肢を、parseAsBooleanでコンパクト表示のようなオン・オフの設定を型安全に管理できます。
import { useQueryStates, parseAsStringEnum, parseAsBoolean } from 'nuqs'
function useDashboardSettings() {
return useQueryStates({
period: parseAsStringEnum(['7d', '30d', '90d']).withDefault('30d'),
compact: parseAsBoolean.withDefault(false),
})
}
下のサンプルでは、期間ボタンとコンパクト表示のトグルを切り替えるたびに、URLのクエリ文字列と表示が連動して変わります。
compactをparseAsBooleanからparseAsStringEnum(['card', 'table'])のような複数値の切り替えに変えれば、オン・オフの2択ではなく表示モードを3種類以上に増やすこともできます。
フォームのウィザード
ステップ形式のフォームで現在のステップをURLに保持しておけば、途中でブラウザを閉じても続きから再開できます。これ、ユーザーからするとかなりありがたい機能なんですよね。
ステップ管理にはuseQueryStateとparseAsInteger、そして更新時のhistory: 'push'オプションの組み合わせが便利です。historyを'push'にすると、ステップを進めるたびにブラウザの履歴に追加されるので、戻るボタンで1つ前のステップに戻れるようになります(デフォルトはURLを置き換えるだけの'replace'です)。
import { useQueryState, parseAsInteger } from 'nuqs'
function useWizardStep() {
return useQueryState('step', parseAsInteger.withDefault(1))
}
// ブラウザの履歴に残したい更新だけ history: 'push' を指定する
setStep(step + 1, { history: 'push' })
下のサンプルは3ステップのフォームウィザードです。「次へ」を押すとstepパラメータとURLの履歴が進み、「戻る」を押すとhistory: 'push'を付けずに1つ前へ戻ります。
「次へ」で呼んでいるsetStep(step + 1, { history: 'push' })だけがブラウザの履歴に記録されるので、ステップを進めたときはブラウザバックで前のステップに戻り、「戻る」ボタンでの巻き戻しは履歴を汚さない、という使い分けができています。
タブやモーダルの状態管理
タブの選択状態やモーダルの開閉状態をURLに持たせることで、直リンクでその状態を再現できます。「設定画面の○○タブを開いて」っていう説明が不要になります。
タブの選択にはparseAsStringEnum、モーダルの開閉にはparseAsBooleanを使うと、どちらもURLクエリパラメータとして型安全に管理できます。
import { useQueryState, parseAsStringEnum, parseAsBoolean } from 'nuqs'
function useTabAndModal() {
const [tab, setTab] = useQueryState(
'tab',
parseAsStringEnum(['profile', 'settings']).withDefault('profile')
)
const [modalOpen, setModalOpen] = useQueryState(
'modal',
parseAsBoolean.withDefault(false)
)
return { tab, setTab, modalOpen, setModalOpen }
}
下のサンプルはタブ切り替えとモーダルの開閉を、それぞれ独立したURLクエリパラメータとして管理する例です。
モーダルを開いた状態のURL(?modal=true)をそのまま共有すれば、相手の画面でも同じモーダルが開いた状態を再現できます。tabs配列に'billing'を追加すれば、タブをもう1つ増やせます。
まとめ
Nuqsを使うと、URLクエリパラメータの管理が劇的に楽になります。
useStateと同じ感覚で使える- 型安全にパラメータを扱える
- シェア可能なURLが簡単に作れる
- ブラウザバックにも対応できる
正直なところ、「なんで今まで自前で実装してたんだろう」という気持ちになりました。バンドルサイズも6kB程度と軽量なので、導入のハードルも低いです。
検索フィルターやページネーションを実装する機会がある方は、ぜひ試してみてください。QOL上がりますよ。