🦊

nuqsの導入でURL状態管理が別次元になった件

に公開

はじめに

最近、GitHubのスター数が多いリポジトリを見てコードリーディングすることが多くなってきたのですが、その中で目的のリポジトリを探すのがめんどくさくなってきました。と同時に nuqsの理解を深めるという目的もあり、こういうのは実際に作りながら学ぶのが一番早いってことで「検索 → 詳細 → 関連リポジトリ」までサクッと辿れるやつを作りました。

nuqsの理解を深めるというのが一番の目的だったので副産物的に出来上がったものですが、意外と使えそう?と思ったので一応デプロイしました。

GH Seeker

各自、GitHubToken設定すればそのまま使えます
https://gh-seeker.vercel.app/

何ができる?

  • GitHubリポジトリを検索
  • Star/ Fork / 更新日時でソート
  • 検索結果内から言語フィルタリング
  • 関連リポジトリの表示

技術スタックざっくり

  • Next.js,TypeScript
  • Tanstack Query
  • Nuqs ※ 今回の目的はこいつ
  • iron-session
  • Upstash Redis(任意でRate Limit)
  • Zod(APIレスポンス検証)

nuqsでURLと状態を同期する

https://www.youtube.com/watch?v=qpczQVJMG1Y

まずnuqsとは何か?
Never Underestimate Query Stateの略で、ざっくりいうと「URLクエリパラメータを stateとして扱うことを目的にしたライブラリ」です。

https://nuqs.dev

何が便利なのか?

まずは簡単な例ですが、通常のuseSearchParamsを使用した実装だと

const searchParams = useSearchParams()
const router = useRouter()

const page = Number(searchParams.get("page") ?? 1)

// ユーザー操作時にURLを更新
const goToPage = (next: number) => {
  const params = new URLSearchParams(searchParams)
  params.set("page", String(next))
  router.replace(`?${params.toString()}`)
}

気にするところ

  1. searchParamspageというstateとURLの二重管理
  2. stringnumber/booleanの変換漏れ
  3. replace/pushの使い分けが煩雑
  4. デフォルト値・未指定時の扱いが不統一
  5. 型安全か?(string | null

それではnuqs使う場合はどうなるでしょうか?

const [page, setPage] = useQueryState(
  "page",
  parseAsInteger.withDefault(1)
)

できること

  1. URLとstateが自動で同期
  2. 型が number / boolean / enum として扱える
  3. デフォルト値が宣言的
  4. useEffect不要
  5. 履歴操作も制御可能

useQueryState

検索フォームはuseQueryStateでURLと同期可能です。
送信するとqpageがURLに入るので、共有URLでも同じ検索結果が出せます。

ざっくりこんな感じ

const [query, setQuery] = useQueryState("q", {
  defaultValue: "",
  shallow: false,
});
const [, setPage] = useQueryState("page", {
  defaultValue: "1",
  shallow: false,
});

フォーム送信時にqを更新し、ページを1に戻す実装です。

今回の場合

  • 「q, sort, page, per_page, language」をuseQueryStateで取得
  • 入力値の正規化(無効なsortやpageを補正)
  • Tanstack Queryで検索
  • 事前fetchでソート切り替え時の体感を改善
    など、フックの中で 「状態の置き場をURLに寄せることができる」 のがnuqsの良さって感じでしょうか。

一旦整理すると

「URLのクエリをReactのstateみたいに扱える」 ので、状態をローカルに持たず URLを状態の置き場にできます。結果として、以下が自然にできるようになります。

  1. 共有可能(URLを貼れば同じ状態が再現)
  2. リロードで状態が消えない
  3. 戻る/進むが状態遷移として機能する
  4. SSR/CSRの境界でも一貫する

従来(nuqs なし)

URL から searchParams.get() で値を取る
string → number / boolean に変換する
それを useState に入れる
値が変わったら router.push / replace で URL を更新する
👉 URL と state を手動で同期していた

nuqs を使うと

URL がそのまま state になる
state を更新すると 自動で URL も更新される
URL が変わると 自動で state も更新される
👉 取得・埋め込み・同期の作業が不要になる

つまり、今まで、URLからクエリパラメーター取得してstateに保存して使用したり、その逆にURLに入力値を埋め込んだりしていたのが、URLとstateが同期してるので「取得、埋め込み作業が大幅にカット」されて、しかも型安全に実装できます。

補足: createSerializerが地味に便利

詳細ページなどに遷移するとき、検索条件を引き継いで戻れるようにしたいとします。その場合、
createSerializerでURLの整形をまとめておくと、リンク生成がスッキリします。

import { createSerializer, parseAsInteger, parseAsString } from "nuqs";

// 検索パラメータのパーサー定義
export const searchParamsParsers = {
  q: parseAsString,
  repoId: parseAsInteger,
  language: parseAsString,
  sort: parseAsString,
  order: parseAsString,
  page: parseAsInteger,
  per_page: parseAsInteger,
};

// 検索パラメータをURLクエリ文字列にシリアライズ
export const serializeSearchParams = createSerializer(searchParamsParsers);

使用部分

  const href = serializeSearchParams(
    `/repository/${repository.owner.login}/${repository.name}`,
    {
      q: searchQuery?.trim() || null,
      repoId: repository.id,
      language: language || null,
      sort: sort || null,
      order: order || null,
      page: page && page > 1 ? page : null,
      per_page: perPage ?? null,
    },
  );

nullにしたものはURLに出ないので、余計なクエリが増えません。

ちょっと工夫したポイント

  • リポジトリ名が変更されてもいいようにキャッシュはrepoのIDで
  • ソート切替の事前フェッチで体感速度を上げる
  • 言語フィルタは検索結果から動的生成
  • 関連リポジトリの表示

まとめ

nuqsは「URLの状態管理って面倒…」を一気に楽にしてくれるライブラリでした。
useQueryState と createSerializer だけでもかなり体験が良くなるのでおすすめです。

もし「スター数の多いリポジトリをよく読む」タイプなら、
このアプリはかなりしっくりくると思います。

Discussion