nuqsでReactのURLのクエリパラメータを状態管理として扱う方法

nuqsとは

nuqsとは、主にReactやNext.jsでURL のクエリパラメータを安全に状態管理するためのライブラリです。

useStateの代わりにuseQueryStateを使うことで、keywordやcountなどを型付きでURLのクエリパラメータで状態管理が可能になります。

※ ちなみにVue.jsやVanilla JSはuseQueryStateが使えないので実質使用不可です。

ReactのuseStateでkeywordとcountを状態管理したい場合は、以下のようなコードになります。

これだとブラウザを更新したり、別のページに遷移して戻った際に状態を維持できないというデメリットがあります。

そのため、検索ページや商品一覧ページのような検索、ページネーション、フィルター(絞り込み)などが必要なページには不向きです。

App.tsx
import { useState } from 'react'

function App() {
  const [keyword, setKeyword] = useState<string | null>(null)
  const [count, setCount] = useState<number>(0)

  return (
    <>
      <input
        value={keyword || ''}
        onChange={(e) => setKeyword(e.target.value)}
      />
      <button onClick={() => setCount((c) => (c ?? 0) + 1)}>+1</button>
      <button
        onClick={() => {
          setKeyword(null)
          setCount(0)
        }}
      >
        Clear
      </button>
      <p>Hello, {keyword || 'World'}!</p>
      <p>Count is {count}</p>
    </>
  )
}

export default App

Reactだけでkeywordとcountを状態管理するサンプル

nuqsを使用してuseStateの代わりにuseQueryStateを使用した場合は、URLのクエリパラメータで状態管理が可能になるため、Webページ遷移後に戻ったり、パラメータ付きのURLでアクセスしたときに、元の状態を維持してWebページを表示することができます。

App.tsx
import { useQueryState, parseAsInteger } from 'nuqs'

function App() {
  const [keyword, setKeyword] = useQueryState('keyword', { defaultValue: '' })
  const [count, setCount] = useQueryState(
    'count',
    parseAsInteger.withDefault(0)
  )

  return (
    <>
      <input
        value={keyword || ''}
        onChange={(e) => setKeyword(e.target.value)}
      />
      <button onClick={() => setCount((c) => (c ?? 0) + 1)}>+1</button>
      <button
        onClick={() => {
          setKeyword(null)
          setCount(null)
        }}
      >
        Clear
      </button>
      <p>Hello, {keyword || 'World'}!</p>
      <p>Count is {count}</p>
    </>
  )
}

export default App

Reactとnuqsでkeywordとcountを状態管理するサンプル

以下のURLでアクセスすると「keyword=John&count=3」の状態でWebページが表示されます。

入力からURLに反映されるまでにワンテンポ遅れるのは、debounceを500ミリ秒に別途設定しているからです。(詳細は後述)

https://react-and-nuqs.vercel.app/?keyword=John&count=3

Clearボタンを押すと、setKeyword(null)とsetCount(null)が実行されて、パラメータがクリアされます。

従来の検索ページや商品一覧ページなどでは、状態管理しているパラメータをクリアしても「keyword=&count=&page=」のように値だけ消えて、キー名だけ残って見栄えが悪くなっていることが多いです。

nuqsではnullでクリアした場合はキーと値のどちらも削除してくれますので、URLの可読性が良いです。

nuqsの読み方と略

nuqsの読み方は「ナックス」でducks (ダックス)のような発音です🦆

元のパッケージ名が「Next-UseQueryState」だったので、こちらの頭文字からnuqsと略して命名されました。

パッケージ名が変更された理由は「長すぎて入力するのが面倒だから」とnuqsの作者のfranky47氏が公式サイトで語っています。

About the name | nuqs

日本人の中にはムーザルちゃんねるという有名なYouTubeチャンネルが「Never underestimate query strings.」の略だという作者の冗談を紹介したため、これがnuqsの略だと誤解している方が他国よりも多いようです。

nuqsの使い方

この記事ではReactでnuqsを使用する方法について解説します。

まず、Reactの環境が用意できたら、「npm i nuqs」でnuqsをインストールします。

npm i nuqs

nuqsをインストールしたら、main.tsxにNuqsAdapterをインポートして<App />を囲みます。

NuqsAdapterにはdefaultOptionsに「limitUrlUpdates: debounce(500)」を設定して、入力から更新されるまでの頻度を500ミリ秒に設定しています。

何も設定していない状態だと文字を入力するたびに反映されるため、反映のたびにAPIを実行してしまったり、履歴が1文字入力するたびに残ってしまうデメリットがあります。

ただし、500ミリ秒更新を遅延させると、変更してから検索ボタンを押すまでの時間が500ミリ秒以内だと状態が反映される前に送信してしまうので、そのようなことが可能な場合はボタンを処理が反映されるまでdisabledにするなどの措置が別途必要です。

main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { NuqsAdapter } from 'nuqs/adapters/react'
import { debounce } from 'nuqs'
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <NuqsAdapter
      defaultOptions={{
        limitUrlUpdates: debounce(500),
      }}
    >
      <App />
    </NuqsAdapter>
  </StrictMode>,
)

debounceはuseQueryStateごとに個別に設定することもできますが、それをやると手間がかかり、設定忘れが発生する可能性もあるので、NuqsAdapterに設定して共通で反映したほうが楽です。

ちなみに履歴を残さない設定もできますが、ボタンの戻るボタンを押したときに前の状態に戻れなくなるなどの問題が発生する可能性があるため、使用する際は注意が必要です。

NuqsAdapterの設定が完了したら、あとはnuqsからuseQueryStateやparseAsIntegerをインポートして、useStateのように状態管理すれば完了です。

App.tsx
import { useQueryState, parseAsInteger } from 'nuqs'

function App() {
  const [keyword, setKeyword] = useQueryState('keyword', { defaultValue: '' })
  const [count, setCount] = useQueryState('count', parseAsInteger.withDefault(0))

  return (
    <>
      <input value={keyword || ''} onChange={(e) => setKeyword(e.target.value)} />
      <button onClick={() => setCount(c => (c ?? 0) + 1)}>+1</button>
      <button onClick={() => { setKeyword(null); setCount(null); }}>Clear</button>
      <p>Hello, {keyword || 'World'}!</p>
      <p>Count is {count}</p>
    </>
  )
}

export default App;

useStateと違って、useQueryStateは第1引数にURLのパラメータのキー名、第2引数にデフォルトの値を設定します。

多くの場合は文字列なら{ defaultValue: '' }、数値ならparseAsInteger.withDefault(0) (または1)を初期値に設定します。

ほかにもBooleanや配列なども用意されているので、用途に応じて使用します。

Built-in parsers | nuqs

URLのクエリパラメータの文字数の上限は2000文字

nuqsの作者によるとURLのクエリパラメータの文字数の上限は実質2000文字程度です。

ほとんどのWebページではURLのクエリパラメータの文字数が2000文字以上になることはありませんが、配列を使用して大量のデータを状態管理している場合は2000文字を超える可能性があります。

また、日本語の場合は自動的にエンコードされるため、ブラウザのアドレスバーに表示される文字数よりも実際にはもっと多くなっている点にも注意が必要です。

https://my-nuqs-search.vercel.app/?keyword=佐藤

https://my-nuqs-search.vercel.app/?keyword=%E4%BD%90%E8%97%A4

Next.jsならサーバーサイドにも型を使用可能

この記事はReactでの解説の記事なので詳細は割愛しますが、nuqsはNext.jsならサーバーサイドにも同じ型を使用可能です。

nuqsのパーサーをそのまま利用できるため、クライアントとサーバーで同じ型定義・同じバリデーションロジックを共有できるという利点があります。

これにより、クエリパラメータの仕様が片側だけズレてしまう問題を防ぎ、URLを扱う処理をより安全かつ一貫性のあるものにできます。

Server-Side usage | nuqs

nuqsのページネーション付き検索ページのサンプル

実際にnuqsを使用したページネーション付き検索ページのサンプルを作成してみました。

以下のdummyjsonからfetchでid, title, priceを取得して、ページネーションで10件ごとに表示して、入力欄から絞り込みができるというサンプルです。

https://dummyjson.com/products?select=title,price&limit=194

URLパラメータで状態管理されているので、「a」を入力して、「?keyword=a」の状態で下部のiwb.jpのリンクから遷移して戻っても「?keyword=a」が維持されています。

keywordを変更した際はページに不整合が発生しないよう、goToPage(1)でページ数を1にリセットしています。

最初に述べた通り、nuqsではデフォルト値の場合はキーがURLから取り除かれるので「page=1」がurlに付くことはありません。

「page=1」がurlに付くURLと付かないURLの2パターンのURLがあると、SEO的にはマイナスなので、「page=1」のパラメータは付けないほうが適切です。

App.tsx
import { useEffect, useMemo, useState } from 'react'
import { useQueryState, parseAsInteger } from 'nuqs'
import './App.css'

type Product = {
  id: number
  title: string
  price: number
}

const PAGE_SIZE = 10
const PRODUCTS_ENDPOINT = 'https://dummyjson.com/products?select=title,price&limit=194'

function App() {
  const [keyword, setKeyword] = useQueryState('keyword', { defaultValue: '' })
  const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
  const [products, setProducts] = useState<Product[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    const controller = new AbortController()
    async function loadProducts() {
      try {
        setLoading(true)
        const response = await fetch(PRODUCTS_ENDPOINT, { signal: controller.signal })
        if (!response.ok) {
          throw new Error('Failed to load products')
        }
        const data = await response.json()
        setProducts(data.products ?? [])
        setError(null)
      } catch (err) {
        if (err instanceof DOMException && err.name === 'AbortError') {
          return
        }
        setError(err instanceof Error ? err.message : 'Unknown error')
      } finally {
        setLoading(false)
      }
    }

    loadProducts()
    return () => controller.abort()
  }, [])

  const filteredProducts = useMemo(() => {
    const query = keyword.trim().toLowerCase()
    if (!query) {
      return products
    }
    return products.filter(product => product.title.toLowerCase().includes(query))
  }, [keyword, products])

  const totalPages = Math.max(1, Math.ceil(filteredProducts.length / PAGE_SIZE))
  const currentPage = Math.min(Math.max(page, 1), totalPages)

  useEffect(() => {
    if (page !== currentPage) {
      setPage(currentPage)
    }
  }, [page, currentPage, setPage])

  const paginatedProducts = filteredProducts.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE)

  const goToPage = (target: number) => {
    const next = Math.min(Math.max(target, 1), totalPages)
    setPage(next)
  }

  return (
    <div className="page">
      <div className="controls">
        <label>
          Keyword:{' '}
          <input
            value={keyword || ''}
            onChange={(event) => {
              setKeyword(event.target.value)
              goToPage(1)
            }}
            placeholder="Search by title..."
          />
        </label>
      </div>

      {loading && <p>Loading products…</p>}
      {error && <p className="error">Error: {error}</p>}

      {!loading && !error && (
        <div className="table-card">
          <div className="table-wrapper">
            <table>
              <thead>
                <tr>
                  <th>ID</th>
                  <th>Title</th>
                  <th>Price</th>
                </tr>
              </thead>
              <tbody>
                {paginatedProducts.length === 0 ? (
                  <tr>
                    <td colSpan={3} className="empty">
                      No products match the current filter.
                    </td>
                  </tr>
                ) : (
                  paginatedProducts.map(product => (
                    <tr key={product.id}>
                      <td>{product.id}</td>
                      <td>{product.title}</td>
                      <td className="price">${product.price.toFixed(2)}</td>
                    </tr>
                  ))
                )}
              </tbody>
            </table>
          </div>

          <div className="pagination">
            <button onClick={() => goToPage(currentPage - 1)} disabled={currentPage === 1}>
              Previous
            </button>
            <span>
              Page {currentPage} / {totalPages}
            </span>
            <button onClick={() => goToPage(currentPage + 1)} disabled={currentPage === totalPages}>
              Next
            </button>
          </div>
        </div>
      )}
      <p><a href='https://iwb.jp/'>iwb.jpにアクセスする</a></p>
    </div>
  )
}

export default App

nuqsのページネーション付き検索ページのサンプル