イカとタコのイラスト

型安全にクエリパラメーターを扱う nuqs

フロントエンドの状態管理のパターンとしてクエリパラメータを信頼できる唯一の情報源(single source of truth)として扱うことがあります。ですが、クエリパラメーターの型が文字列であるため、型安全性が保証されないという課題があります。この記事では `nuqs` というライブラリを使用してクエリパラメーターを型安全に扱う方法について解説します。

フロントエンドの状態管理のパターンとしてクエリパラメータを信頼できる唯一の情報源(single source of truth)として扱うことがあります。つまり、useState などの React の状態管理フックを使用してメモリ上に保持した状態を使用するのではなく、location.search などでクエリパラメーターを取得し、それの情報を元に画面を描画するということです。ユーザーの操作により状態が更新される場合には必ずクエリパラメータも更新することで、状態とクエリパラメータが常に一致することが保証します。

クエリパラメータを状態の情報源として使用するメリットとして以下のようなものがあります。

  • ブラウザの履歴に状態を保存できるため、ブラウザの戻る・進むボタンで状態を戻すことができる
  • ブックマークや URL を共有することで状態を再現できる。例えばタブの状態を URL に含めることで特定のタブを開いた状態を共有できる
  • アプリケーションの操作に慣れているパワーユーザーは UI を使わずに URL を直接操作することで状態を変更できる

一方で、クエリパラメータを状態の情報源として使用する際には以下のような課題があります。

  • クエリパラメーターの型が文字列であるため、型安全性が保証されない
  • クエリパラメーターのパースやシリアライズやクエリパラメーターの操作などの処理が煩雑になる

このような課題を解決するために nuqs というライブラリが登場しました。nuqs はクエリパラメーターを型安全に扱うためのライブラリです。クエリパラメーターを useState とよく似た API で扱うことができます。

Note

nuqs はもともと next-usequerystate という名前で呼ばれていましたが、タイピングするのに長過ぎるという理由で nuqs に変更されたようです。

この記事では Next.js で nuqs を使用してクエリパラメーターを型安全に扱う方法について解説します。

インストール

nuqs@^2 は以下のフレームワークをサポートしています。

  • Next.js: 14.2.0 and above (including Next.js 15)
  • React SPA: 18.3.0 & 19 RC
  • Remix: 2 and above
  • React Router v6: react-router-dom@^6
  • React Router v7: react-router@^7

上記よりも古い Next.js のバージョンを使用している場合には nuqs@^1 を使用する必要があります。

以下のコマンドで nuqs をインストールします。

npm install nuqs

続いて nuqs をフレームワークに統合するために <NuqsAdapter> Context Provider を使用します。Next.js App Router の場合には src/app/layout.tsx に以下のように記述します。

src/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>
  );
}

基本的な使い方

nuqsuseQueryState というフックを使用して状態を管理します。useQueryState の第 1 引数にはクエリパラメーターのキーを指定します。useQueryState の返り値は useState と同じように [state, setState] のタプルです。デフォルトの型は string | null です。

以下の例では name というクエリパラメーターを管理しています。useQueryState フックはクライアントコンポーネントのみで使用可能なため、"use client" ディレクティブを宣言しています。

app/page.tsx
"use client";
import { useQueryState } from "nuqs";
 
export default function Home() {
  const [name, setName] = useQueryState("name");
 
  return (
    <div>
      <label htmlFor="name">Name:</label>
      <input
        id="name"
        type="text"
        value={name ?? ""}
        onChange={(e) => setName(e.target.value)}
      />
      <p>Hello, {name}!</p>
    </div>
  );
}

以下のように input 要素の入力するたびにクエリパラメーターが更新されることが確認できます。またブラウザの URL を直接操作して状態を変更することもできます。なお、デフォルトでは状態を更新してもブラウザの履歴に追加されることはありません。

デフォルト値の指定

useQueryState で指定したキーに対応するクエリパラメーターが存在しない場合にはデフォルトでは null が返ります。デフォルト値を指定する場合には useQueryState の第 2 引数のオブジェクトの defaultValue プロパティにデフォルトに指定します。defaultValue を指定した場合、返り値の型は string になります。

const [name, setName] = useQueryState("name", { defaultValue: "world" });

パーサーを指定する

useQueryState で指定したクエリパラメーターの値はデフォルトでは string 型になります。実際のプロダクトでは number, boolean, Date, array, object など様々な型を状態として扱いことでしょう。string 以外の型を状態として扱いたい場合にはパーサーを useQueryState の第 2 引数に指定します。

よく使われる型については nuqs ビルドインのパーサーが用意されています。より複雑な型を扱う場合にはカスタムパーサーを指定することもできます。

ビルドインのパーサーとしては以下のものが用意されています。

  • String: parseAsString
  • Integer: parseAsInteger
  • Float: parseAsFloat
  • Hexadecimal: parseAsHex
  • Boolean: parseAsBoolean
  • String Literal Types: parseAsStringLiteral
  • Number Literal Types: parseAsNumberLiteral
  • Enum: parseAsEnum
  • ISO 8601 Datetime: parseAsIsoDateTime
  • ISO 8601 Date: parseAsIsoDate
  • timestamp: parseAsTimestamp
  • Array: parseAsArray
  • JSON: parseAsJson

例としてカウンターアプリケーションを作成する場合を考えます。クエリパラメーター count は数値を表すため、parseAsInteger を指定します。parseAsInteger は数値にパースできる場合には数値を返し、パースに失敗するような値(?name=foo)が指定された場合にはデフォルト値(null)が返ります。

"use client";
import { parseAsInteger, useQueryState } from "nuqs";
 
export default function Counter() {
  const [count, setCount] = useQueryState("count", parseAsInteger);
  // ^? number|null
 
  return (
    <div>
      <button onClick={() => setCount((c => c+1))>Increment</button>
      <p>Count: {count ?? 0}</p>
    </div>
  );
}

デフォルト値を指定しつつパーサーを指定する場合には .withDefault メソッドを使用します。

const [count, setCount] = useQueryState("count", parseAsInteger.withDefault(0));

JSON パーサーを使用する

オブジェクト型として状態を管理する場合には parseAsJson パーサーを使用します。JSON オブジェクトのスキーマやバリデーションを行うためには ZodYup, Valibot などのスキーマバリデーションライブラリを使用します。

"use client";
import { z } from "zod";
import { parseAsJson, useQueryState } from "nuqs";
 
// zod のスキーマを定義
const schema = z.object({
  query: z.string(),
  page: z.number(),
  sort: z.enum(["price", "date", "rating"]),
  primeDelivery: z.boolean(),
  tags: z.array(z.string()),
});
 
export default function Search() {
  // zod で定義したスキーマ | null 型に推論される
  const [search, setSearch] = useQueryState(
    "search",
    parseAsJson(schema.parse),
  );
 
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const formData = new FormData(e.target as HTMLFormElement);
    // クエリパラメーターを更新
    setSearch({
      query: String(formData.get("query") ?? ""),
      page: Number(formData.get("page")) ?? 1,
      sort: (formData.get("sort") ?? "price") as "price" | "date" | "rating",
      primeDelivery: formData.get("primeDelivery") === "on",
      tags: String(formData.get("tags") ?? "")
        .split(",")
        .map((tag) => tag.trim()),
    });
  };
 
  return {
    /* ... */
  };
}

setSearch を呼び出して状態を更新すると、以下のようなクエリパラメーターが URL に追加されます。

?search={"query":"react","page":1,"sort":"price","primeDelivery":true,"tags":["book","development"]}

クエリパラメータのパースに失敗(schema.parse でエラーが発生)した場合にはデフォルト値(null)が返ります。

オプション

nuqs のいくつかの挙動は以下のオプションを使用してカスタマイズできます。

  • history:ブラウザの履歴に状態を追加するかどうかを指定する。デフォルトは history.replace() が使われるためクエリパラメーターが更新されてもブラウザの履歴に追加されない
  • shallow:デフォルトではクライアントサイドのみでクエリパラメータが更新され、サーバーサイドへのリクエストは行われない。shallow オプションを true にすることでサーバーサイドへ更新が通知される。Next.js App Router の場合は shallow オプションを true に設定すると RSC ツリーが再レンダリングされる
  • scroll:デフォルトではクエリパラメーターが更新されてもスクロール位置が維持される。scroll オプションを true にすることでクエリパラメーターが更新された際にスクロール位置がトップに戻る
  • throttleMs:ブラウザによる History API の呼び出し制限を回避するためクエリパラメータの更新はキューに追加され、指定した時間(ミリ秒)ごとに処理される。デフォルトは 50 ミリ秒でこの値は設定により変更できる(Safari では厳しい制限が設定されていて 120 ミリ秒以上の間隔で更新する必要がある)
  • startTransition:React の useTransition フックと組み合わせてクエリパラメーターを更新する
  • clearOnDefault:デフォルトでは状態がデフォルト値に更新された場合、クエリパラメーターが削除される。clearOnDefault オプションを false にすることでデフォルト値に更新されてもクエリパラメーターが削除されない

オプションを指定する方法は 2 つあります。1 つ目は useQueryState の第 2 引数にオプションを指定する方法です。

const [name, setName] = useQueryState("name": { history: "push" });

この方法は状態を更新する関数を呼び出す場合にも使用できます。この場合 useQueryState() で指定したオプションは上書きされます。

setName("Alice", { scroll: true });

2 つ目の方法はパーサーの builder パターンを使用する方法です。

const [name, setName] = useQueryState(
  "name",
  parseAsString.withDefault("Alice").withOptions({ scroll: true }),
);

Tip

parseAsString パーサーはデフォルトと同じ string 型を返すパーサーであり一見何も行わないように見えます。parseAsString は上記のように他のパーサーを使用した場合と同じインターフェイスである builder パターンを使用してオプションを指定したい場合に便利です。

サーバーサイドでの使用

サーバーサイドでクエリパラメータをパースする場合には loader 関数を使用します。loader 関数は createLoader 関数を使用して作成します。サーバーサイドで使用する API は nuqs/server からインポートします。

import { parseAsString, parseAsInteger, createLoader } from "nuqs/server";
 
const parser = {
  name: parseAsString.withDefault("world"),
  count: parseAsInteger.withDefault(0),
};
const loadSearchParams = createLoader(parser);

loadSearchParams 関数はパース済みのクエリパラメーターを返します。loadSearchParams 関数は req オブジェクトを受け取り、クエリパラメーターをパースして返します。

import type { SearchParams } from "nuqs/server";
 
type PageProps = {
  searchParams: Promise<SearchParams>;
};
 
export default async function Page({ searchParams }: PageProps) {
  const { name, count } = await searchParams;
 
  return (
    <div>
      <p>Hello, {name}!</p>
      <p>Count: {count}</p>
    </div>
  );
}

Warning

loader 関数はデータの検証を行わないことに注意してください。データの検証を行う場合には zod のようなスキーマバリデーションライブラリを使用する必要があるでしょう。組み込みのデータ検証を行う REC が提案されていますが、まだ実装されていません。

searchParams に直接アクセスできない子サーバーコンポーネントからクエリパラメーターを取得したい場合には createSearchParamsCache 関数を使用します。この関数は内部で React の cache() 関数を使用してクエリパラメーターをキャッシュします。なお、この関数は現在 Next.js でのみ使用可能です。

import {
  createSearchParamsCache,
  parseAsString,
  parseAsInteger,
  type SearchParams,
} from "nuqs/server";
 
const parser = {
  name: parseAsString.withDefault("world"),
  count: parseAsInteger.withDefault(0),
};
const searchParamsCache = createSearchParamsCache(parser);
 
type PageProps = {
  searchParams: SearchParams;
};
 
export default function Page({ searchParams }: PageProps) {
  // .parse() メソッドは必ず呼び出す必要があることに注意
  const { name } = await searchParamsCache.parse(searchParams);
 
  return (
    <div>
      <p>Hello, {name}!</p>
      <ChildComponent />
    </div>
  );
}
 
function ChildComponent() {
  const count = searchParamsCache.get("count");
 
  return <p>Count: {count}</p>;
}

createSearchParamsCache で作成したキャッシュは get メソッドを使用してクエリパラメーターを取得します。.parse() メソッドの結果を使用しない場合でも、必ず呼び出す必要がある点に注意してください。.parse() メソッドを呼び出さずに .get() メソッドを使用するランタイムエラーが発生します。

キャッシュされた値を呼び出せるのはサーバーコンポーネントに限られます。"use client" ディレクティブを宣言したクライアントコンポーネントからはアクセスできません。

複数のクエリパラメーターをバッチで更新する

いくつかの状態は一体不可分なことがあります。例えば緯度と経度を表す latlng は一緒に更新されるべきです。このような同時に更新されるべき状態を扱う場合には useQueryStates フックを使用します。

"use client";
import { useQueryStates, parseAsFloat } from "nuqs";
 
const [{ lat, lang }, setCoordinates] = useQueryStates(
  {
    lat: parseAsFloat.withDefault(35.6895),
    lng: parseAsFloat.withDefault(139.6917),
  },
  {
    history: "push",
  },
);
 
const moveToKyoto = () => {
  setCoordinates({
    lat: 35.0116,
    lng: 135.7681,
  });
};

URL 用のクエリパラメーターの名前を指定する

プログラミングにおける命名は重要です。後からコードを読む人や他の開発者が理解しやすいように意味がある名前をオブジェクトのキーとして指定するべきでしょう。しかし、クエリパラメーターとして状態を管理する場合には命名規則との間にトレードオフが存在します。

ほとんどのブラウザでは URL の文字列には制限があり、おおよそ 2,000 文字程度までしか URL に含めることができません。そのため、複雑な状態を URL に収めたい場合にはキー名をできる限り短くするといった工夫が必要です。ですが短いキー名を採用すると、コードの可読性が低下する可能性があります。

この問題を解決するために useQueryStates フックのオプションで urlKeys を指定できます。urlKeys はオブジェクトのキーと URL 用のクエリパラメーターの名前をマッピングするオブジェクトです。

import { useQueryStates, parseAsFloat } from "nuqs";
 
const [{ latitude, longitude }, setCoordinates] = useQueryStates(
  {
    latitude: parseAsFloat.withDefault(35.6895),
    longitude: parseAsFloat.withDefault(139.6917),
  },
  {
    history: "push",
    urlKeys: {
      latitude: "lat",
      longitude: "lng",
    },
  },
);
 
const moveToKyoto = () => {
  setCoordinates({
    latitude: 35.0116,
    longitude: 135.7681,
  });
};

テスト

jsdomhappy-dom のような一般的なテスト環境では URL が保存されません。そのためクエリパラメーターの取得や更新といったテストを行う場合には大抵の場合、モックを使用するといった工夫が必要です。

nuqs では withNuqsTestingAdapter をラッパーとして使用することでテスト環境でのクエリパラメーターのテストを容易にします。下記の例では Vitest と React Testing Library を使用してカウンターアプリケーションのテストを行っています。

import React from "react";
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
  withNuqsTestingAdapter,
  type OnUrlUpdateFunction,
} from "nuqs/adapters/testing";
import { describe, expect, it, vi } from "vitest";
import Counter from "./Counter";
 
describe("Counter", () => {
  it("increment ボタンをクリックすると count が増える", async () => {
    // URL が更新される場合に呼ばれる spy 関数を用意
    const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
    render(<Counter />, {
      wrapper: withNuqsTestingAdapter({
        // クエリパラメーターの初期値を指定
        searchParams: "?count=42",
        onUrlUpdate,
      }),
    });
 
    // count が 42 であることを確認
    expect(screen.getByText("Count: 42")).toBeInTheDocument();
 
    const incrementButton = screen.getByRole("button", { name: "Increment" });
 
    await userEvent.click(incrementButton);
 
    // count が 43 に増えたことを確認
    expect(screen.getByText("Count: 43")).toBeInTheDocument();
 
    // URL が更新されたことを確認
    const event = onUrlUpdate.mock.calls[0][0]!;
    expect(event.queryString).toBe("?count=43");
    expect(event.searchParams.get("count")).toBe("43");
    expect(event.options.history).toBe("replace");
  });
});

まとめ

  • nuqs はクエリパラメーターを型安全に扱うためのライブラリ
  • useQueryState フックを使用してクエリパラメーターを状態として扱う
  • ビルドインのパーサーを使用してクエリパラメーターの型を指定する
  • サーバーサイドでの使用には createLoader 関数を使用する
  • 複数のクエリパラメーターをバッチで更新する場合には useQueryStates フックを使用する
  • URL 用のクエリパラメーターの名前を指定する場合には urlKeys オプションを使用する
  • テスト環境でのクエリパラメーターのテストには withNuqsTestingAdapter を使用する

参考

記事の理解度チェック

以下の問題に答えて、記事の理解を深めましょう。

nuqs においてクエリパラメーターを状態として扱うためのフックはどれか?

  • useExternalState

    もう一度考えてみましょう

  • useQueryParams

    もう一度考えてみましょう

  • useQueryState

    正解!

  • useUrlState

    もう一度考えてみましょう

クエリパラメーターの型を `Integer` として扱う方法として正しいものはどれか?

  • const [count, setCount] = useQueryState('count', parseAsInteger);

    正解!

  • const [count, setCount] = useQueryState('count', parseAsInteger());

    もう一度考えてみましょう

  • const [count, setCount] = parseAsInteger(() => useQueryState('count'));

    もう一度考えてみましょう

  • const [count, setCount] = useQueryState<number>('count');

    もう一度考えてみましょう


Contributors

> GitHub で修正を提案する
この記事をシェアする
はてなブックマークに追加

関連記事