🔗

実践例で考える nuqs を使った Next.js の URL パラメータ管理

2024/10/21に公開

はじめに 🚩

Next.js を例に、URL 状態管理には以下のような課題があると思います。

  • JavaScript の Web API である URLSearchParams を使用してクエリパラメータの直接操作が必要になり、ロジックが煩雑になりがちである
  • 型安全性が確保しづらく、バグの原因になることがある
  • サーバーコンポーネントとクライアントコンポーネントで一貫した方法でクエリパラメータにアクセスすることが難しい

Next.js のタイプセーフな検索パラメータ管理マネージャーである nuqs を使用することで、これらの課題を解決できます。

この記事では、基本的な使い方から実践例までを通して、nuqs の使い方を解説します。

基本的な使い方 📝

まずは、nuqs の基本的な使い方を簡単に説明します。

ボタンのクリックでクエリパラメータを変更する方法

button をクリックした際など、onClick でクエリパラメータを変更する場合は、useQueryState を使用します。

https://nuqs.47ng.com/docs/basic-usage

第一引数に状態の名前、第二引数に状態の値を渡し、返り値には React.useState と同じような使用感で状態の値と、その値を変更するための関数を受け取ることができます。

const [query, setQuery] = useQueryState('query', parseAsString);

<button onClick={() => setQuery('hello')}>Set Query</button>;

ボタンをクリックすると、URL のクエリパラメータに ?query=hello が追加されます。

クエリ文字列のシリアライズ方法

Link(a タグ)コンポーネントの href 属性に状態値を含むクエリ文字列を生成するには、createSerializer ヘルパー関数を使用できます。

https://nuqs.47ng.com/docs/utilities#serializer-helper

createSerializer に検索パラメータを記述したオブジェクトを渡すと、値を受け取って、シリアライズされたクエリ文字列を生成する関数が返されます。

const serialize = createSerializer({ query: parseAsString });
const href = serialize({ query: 'hello' });

<Link href={href}>hello</Link>;

href には /?query=hello が設定されます。

また、createSerializer によって生成されたクエリ文字列生成関数 serialize は、第一引数として URLSearchParams オブジェクトを受け取ることができます。

const searchParams = new URLSearchParams({ hoge: 'fuga' });
const serialize = createSerializer({ query: parseAsString });
const href = serialize(searchParams, { query: 'hello' });

<Link href={href}>hello</Link>;

href には /?hoge=fuga&query=hello が設定されます。

このように、Link コンポーネントの href 属性に動的なクエリパラメータを簡単に追加できます。

文字列リテラルを使ったクエリ管理方法

parseAsStringLiteral 関数を使用すると、特定の文字列リテラルのセットに基づいてクエリパラメータを型安全に管理できます。これにより、許可された値以外のクエリパラメータが設定されることを防ぎます。

type SortType = 'asc' | 'desc';

const SORT_OPTIONS = [
  { label: '降順', value: 'desc' },
  { label: '昇順', value: 'asc' },
] as const satisfies { label: string; value: SortType }[];

const sortParser = parseAsStringLiteral(
  SORT_OPTIONS.map((option) => option.value)
).withDefault('desc');

const [currentValue, setCurrentValue] = useQueryState('content', sortParser);

currentValue は 'asc' | 'desc' のいずれかの値を取ることができます。

また、parseAsStringEnum 関数を使えば、Enum 型を活用してクエリパラメータを型安全に管理することもできます。

配列を使ったクエリ管理方法

parseAsArrayOf 関数を使用すると、クエリパラメータを配列として管理できます。これにより、複数の値を持つクエリパラメータを簡単に操作できます。

例として、ラジオボタンコンポーネントを使ってステータスの選択肢を管理します。
また、zod を使用することで、型安全性を確保しつつ、バリデーションも行うことができます。

// ステータスの定数定義
const ALL_STATUSES = [
  Status.Canceled,
  Status.Pending,
  Status.Requested,
  Status.Started,
  Status.Completed,
] as const;

// ステータスのオプション定義
const STATUS_OPTIONS = [
  { label: 'すべて', value: ALL_STATUSES },
  { label: '作成中', value: Status.Pending },
  { label: '承認待ち', value: Status.Requested },
  { label: '進行中', value: Status.Started },
  { label: '完了', value: Status.Completed },
  { label: '中止', value: Status.Canceled },
] as const satisfies { label: string; value: Status | typeof ALL_STATUSES }[];

// ステータスのスキーマ定義
const StatusSchema = z.union([
  z.literal(Status.Canceled),
  z.literal(Status.Pending),
  z.literal(Status.Requested),
  z.literal(Status.Started),
  z.literal(Status.Completed),
]);

// ステータスのパーサー定義
const statusParser = parseAsArrayOf(
  createParser({
    parse: (query) => {
      const result = StatusSchema.safeParse(query);
      return result.success ? result.data : null;
    },
    serialize: (value) => value,
  })
).withDefault(ALL_STATUSES);

// ステータスの型チェック
const isStatusArray = (value: unknown): value is Status[] => {
  if (!Array.isArray(value)) return false;
  return value.every((v) => Object.values(Status).includes(v));
};

function StatusRadioGroup() {
  const [status, setStatus] = useQueryState('status', statusParser);
  const id = useId();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value === 'すべて' ? ALL_STATUSES : [e.target.value];
    if (isStatusArray(value)) {
      void setStatus(value);
    }
  };

  const isAllSelected = status.length === ALL_STATUSES.length;

  return (
    <div className='flex items-center gap-4'>
      <label htmlFor={id}>ステータス:</label>
      <div className='flex flex-wrap gap-2'>
        {STATUS_OPTIONS.map(({ value, label }) => (
          <Radio
            key={label}
            label={label}
            value={Array.isArray(value) ? 'すべて' : value}
            checked={
              Array.isArray(value)
                ? isAllSelected
                : !isAllSelected && status.includes(value)
            }
            onChange={handleChange}
          />
        ))}
      </div>
    </div>
  );
}

useQueryState の status には すべてを選択した場合は[Status.Canceled, Status.Pending, Status.Requested, Status.Started, Status.Completed] のようになり、すべて以外を選択した場合はそれに応じた Status の配列が入ります。

さらに、nuqs は文字列だけでなく、数値、真偽値、日付など、様々な型をサポートしています。

詳細については、公式ドキュメントを参照してください。

https://nuqs.47ng.com/docs/parsers

実践例 📝

以下それぞれのケース別に、実際のコードを交えて解説します。

オブジェクトを使った複数クエリパラメータの管理方法

商品の絞り込み機能を例に考えてみます。検索クエリ、タグ別、ソート順など、複数のクエリパラメータを一括で管理したいケースがよくあります。

このような場合、useQueryStatesを活用することで、複数のクエリパラメータを 1 つのオブジェクトとしてまとめて管理できるようになります。結果として、個別にクエリパラメータを管理する煩雑さを避けることができます。

https://nuqs.47ng.com/docs/batching#usequerystates

パーサーの定義

コンポーネント別にパラメータを各々のコンポーネントで使いたいので、同じセグメントの _lib/utils.ts のようなファイルを作成して、その中でパーサーを定義しておきます。

import { parseAsInteger, parseAsString } from 'nuqs';

export const searchParamsParser = {
  search: parseAsString.withDefault(''),
  tagId: parseAsInteger,
};

タグ一覧のコンポーネント

tags コンポーネントのロジック処理を一部抜粋します。

const [{ tagId }, setSearchParams] = useQueryStates(searchParamsParser, {
  history: 'push',
});

const handleChange = useCallback(
  (newTagId: number | null) => {
    setOptimisticTagId(newTagId);
    void setSearchParams({ tagId: newTagId });
  },
  [setOptimisticTagId, setSearchParams]
);

tags コンポーネントの全コードは以下です。

/segment/components/tags.tsx
export function MyTags({ tags }: Props) {
  const [{ tagId }, setSearchParams] = useQueryStates(searchParamsParser, {
    history: 'push',
  });

  const [optimisticTagId, setOptimisticTagId] = useOptimistic(tagId);

  const handleChange = useCallback(
    (newTagId: number | null) => {
      setOptimisticTagId(newTagId);
      void setSearchParams({ tagId: newTagId });
    },
    [setOptimisticTagId, setSearchParams]
  );

  return (
    <div
      className='flex flex-wrap gap-2'
      data-pending={isPending ? '' : undefined}
    >
      <ToggleButton
        variant='outline'
        isSelected={!optimisticTagId}
        onPress={() => handleChange(null)}
      >
        All
      </ToggleButton>
      {tags.map((tag) => (
        <ToggleButton
          variant='outline'
          key={tag.id}
          isSelected={tag.id === Number(optimisticTagId)}
          onPress={() => handleChange(tag.id)}
        >
          {tag.name}
        </ToggleButton>
      ))}
    </div>
  );
}

useQueryStates の第一引数には先程のパーサーを渡します。
また第二引数は任意でオプションを付けることが可能で、ここでは画面遷移の履歴を管理する history を指定しています。

また返り値である tagId は nuqs のパーサーである parseAsInteger で定義しているため、型は number となっています。(厳密にはデフォルトで null も許容しているため、number | null となっています。)
そのため、わざわざ文字列 → 数値と型変換を行わなくても良いので便利です。

検索ボックスのコンポーネント

検索ボックスのコンポーネントもタグ一覧と同様の使い方ですが、一点補足すると、useQueryStates の第二引数で clearOnDefault オプションを true に設定しています。これにより、検索パラメータがデフォルト値(空文字)に戻った際に?query=のようにならずに URL から自動的に削除されます。

const [{ search }, setSearchParams] = useQueryStates(searchParamsParser, {
  history: 'replace',
  clearOnDefault: true,
});
/segment/components/search.tsx
import { Button } from '@news-curation/ui/components/button';
import { Icon } from '@news-curation/ui/components/icon';
import {
  SearchField,
  SearchFieldClearButton,
  SearchFieldInput,
} from '@news-curation/ui/components/search-field';
import { cn } from '@news-curation/ui/lib/utils';
import { useQueryStates } from 'nuqs';
import { useRef, useState } from 'react';
import { searchParamsParser } from '../../_lib/search-params';

type Props = {
  className?: string;
};

export function SearchInput({ className }: Props) {
  const [{ search }, setSearchParams] = useQueryStates(searchParamsParser, {
    history: 'replace',
    clearOnDefault: true,
  });
  const [inputValue, setInputValue] = useState(search);
  const inputRef = useRef<HTMLInputElement>(null);
  const debounceTimeout = useRef<NodeJS.Timeout | null>(null);
  const [isSearching, setIsSearching] = useState(false);

  const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setInputValue(value);

    // 既存のタイムアウトがある場合はクリア
    if (debounceTimeout.current) {
      clearTimeout(debounceTimeout.current);
    }

    setIsSearching(true);

    debounceTimeout.current = setTimeout(() => {
      void setSearchParams({ search: value });
      setIsSearching(false);
    }, 500);
  };

  const handleClearSearch = () => {
    void setSearchParams({ search: null });
    setInputValue('');
    setIsSearching(false);
  };

  return (
    <SearchField className={cn('relative', className)}>
      <SearchIcon />
      <SearchFieldInput
        ref={inputRef}
        className='px-10'
        placeholder='検索ワード'
        value={inputValue}
        onChange={handleSearch}
      />
      <SearchActions isSearching={isSearching} onClear={handleClearSearch} />
    </SearchField>
  );
}

page.tsx 側では、クエリパラメータを読み取るだけで十分なため、以下のようにしてクエリパラメータを取得するだけに使用できます。

const [{ search: searchQuery, tagId }] = useQueryStates(searchParamsParser);

このように状態管理ライブラリのような使用感で、クエリパラメータを管理して使いたい値を取得したり、更新したりすることができます。

なお、今回の例ではクライアントコンポーネントでクエリパラメータを管理していますが、サーバーコンポーネントでも同様にクエリパラメータの管理が可能です。この柔軟性により適切な場所でクエリパラメータを型安全に扱うことができます。

詳しくはドキュメントを参照してください。
https://nuqs.47ng.com/docs/server-side

また、関連した複数のクエリパラメータを管理する別の例として、Table コンポーネントがあります。Table コンポーネントでは、ソート順やページネーションなど、複数の状態をクエリパラメータとして管理することが一般的です。

例えば、shadcn-tableという Table コンポーネントを提供するライブラリでは、nuqs を活用して複数のクエリパラメータを効率的に管理していますので、内部コードを覗いてみると参考になるかと思います。

https://github.com/sadmann7/shadcn-table/blob/c6209ec2ac00e81b1a6655dcfeecaf911652e44b/src/hooks/use-data-table.ts#L154-L329

独立したクエリパラメータの管理方法

先程の例のように1つの画面で関連したクエリパラメーターをまとめて管理したいケースもあれば、それぞれのクエリパラメーターが独立しており、干渉し合わずに個別に管理したいケースもあります。

例えば Tabs のようなコンポーネントでは、各 Tab(セクション)ごとに、それぞれのクエリパラメーターを管理したいです。

具体的には以下のようなイメージです。

1

nuqs を使用しない場合、クエリパラメータの管理は複雑になる可能性があります。URLSearchParams を使用してクエリパラメータを直接操作する必要があり、新しいパラメータの設定には set メソッド、削除には delete メソッドを使用し、その後で新しい URL を構築する必要があります。

このアプローチは煩雑で、エラーが発生しやすく、コードの可読性も低下させる可能性があります。nuqs を使用することで、これらの操作をより簡潔に、型安全に行うことができます。

例として React Aria Components を使って Tab に関する汎用的なコンポーネントを作成します。

tabs.tsx
'use client';

import { cn } from '@news-curation/ui/lib/utils';
import { useSearchParams } from 'next/navigation';
import { createSerializer, parseAsString } from 'nuqs';
import { useMemo } from 'react';
import {
  Key,
  Tab as ReactAriaTab,
  TabList as ReactAriaTabList,
  TabPanel as ReactAriaTabPanel,
  Tabs as ReactAriaTabs,
  type TabListProps as ReactAriaTabListProps,
  type TabPanelProps as ReactAriaTabPanelProps,
  type TabProps as ReactAriaTabProps,
  type TabsProps as ReactAriaTabsProps,
} from 'react-aria-components';
import { useTab } from '~/components/tabs/hooks';

export const DEFAULT_TAB_ID = 'defaultTab';

export type TabsProps = ReactAriaTabsProps & {
  className?: string;
};

export function Tabs({ className, ...props }: TabsProps) {
  return <ReactAriaTabs className={className} {...props} />;
}

export function TabList<T extends object>(props: ReactAriaTabListProps<T>) {
  return <ReactAriaTabList {...props} />;
}

export function Tab({ children, ...props }: ReactAriaTabProps) {
  return <ReactAriaTab {...props}>{children}</ReactAriaTab>;
}

type TabItemProps = {
  className?: string;
  id: Key;
  children: React.ReactNode;
};

export function TabItem({ className, id, children }: TabItemProps) {
  const searchParams = useSearchParams();
  const { queryName } = useTab();

  const panelId = id === null ? DEFAULT_TAB_ID : id;

  const href = useMemo(() => {
    const existingSearchParams = new URLSearchParams(searchParams);
    const serialize = createSerializer({
      [queryName]: parseAsString,
    });
    return serialize(existingSearchParams, { [queryName]: id.toString() });
  }, [queryName, id, searchParams]);

  return (
    <Tab
      className={cn(className, 'selected:text-lime-700')}
      href={href}
      aria-controls={panelId}
      id={id}
    >
      {children}
    </Tab>
  );
}

export function TabPanel(props: ReactAriaTabPanelProps) {
  return <ReactAriaTabPanel {...props} />;
}
tab-provider.tsx
import { useQueryState } from 'nuqs';
import { createContext } from 'react';

export type TabContextValue =
  | {
      currentValue: string | null;
      queryName: string;
    }
  | undefined;

export const TabContext = createContext<TabContextValue>(undefined);

type TabProviderProps = {
  defaultValue: string | null;
  queryName: string;
  children: React.ReactNode;
};

export function TabProvider({
  defaultValue,
  queryName,
  children,
}: TabProviderProps) {
  const [searchParams] = useQueryState(queryName);

  return (
    <TabContext.Provider
      value={{
        currentValue: searchParams ?? defaultValue,
        queryName,
      }}
    >
      {children}
    </TabContext.Provider>
  );
}
use-tab.ts
import { use } from 'react';
import { TabContext } from '~/components/tabs/tab-provider';

export function useTab() {
  const context = use(TabContext);
  if (!context) throw new Error('useTab must be used within a TabProvider');

  return context;
}

ここで注目したいのは、tabs.tsx ファイル内の TabItem コンポーネントです。このコンポーネントは、各 Tab の要素を表し、クエリパラメータを利用して状態を制御しています。

export function TabItem({ className, id, children }: TabItemProps) {
  const searchParams = useSearchParams();
  const { queryName } = useTab();

  const panelId = id === null ? DEFAULT_TAB_ID : id;

  const href = useMemo(() => {
    const existingSearchParams = new URLSearchParams(searchParams);
    const serialize = createSerializer({
      [queryName]: parseAsString,
    });
    // 例えばsearchParamsが {contentA: 2} で、
    // 新しく追加されるクエリパラメータが {contentB: 3} の場合、
    // 返り値の結果は ?contentA=2&contentB=3 となる
    return serialize(existingSearchParams, { [queryName]: id.toString() });
  }, [queryName, id, searchParams]);

  return (
    <Tab
      className={cn(className, 'selected:text-lime-700')}
      href={href}
      aria-controls={panelId}
      id={id}
    >
      {children}
    </Tab>
  );
}

Tab コンポーネントの href 属性には、createSerializer 関数で生成したクエリパラメータを渡しています。これにより、各 Tab がクリックされたときに適切なクエリパラメータが URL に追加されます。

また、URLSearchParams の引数には useSearchParams フックを用いて取得した現在のクエリパラメータを渡しています。

これにより、各セクションのクエリパラメータが独立し、既存のクエリパラメータを保持したまま新しいクエリパラメータを追加することができます。(詳しくはコード内のコメントを参照してください)

パスパラメータとクエリパラメータを組み合わせた管理方法

要件によっては、各画面に共通の検索ボックスを設置する必要があります。このような場合、ユーザーが現在いるページから/searchというパスに遷移し、同時に検索ボックスに入力された値をクエリパラメータとして管理したいです。

2

以下は zod や react-hook-form を使用した検索ボックスの例です。

search.tsx
const searchFormSchema = z.object({
  query: z.string().trim().min(1, ErrorMessageType.Required),
});

type SearchFormSchema = z.infer<typeof searchFormSchema>;

export function SearchForm() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const [isPending, startTransition] = useTransition();
  const { register, handleSubmit } = useForm<SearchFormSchema>();

  const onSubmit = ({ query }: SearchFormSchema) => {
    const serialize = createSerializer({
      query: parseAsString.withOptions({
        startTransition,
      }),
    });
    const existingSearchParams = new URLSearchParams(
      pathname.includes('/search') ? searchParams : undefined
    );
    const queryParameter = serialize(existingSearchParams, { query });
    router.push(`/search${queryParameter}`);
  };

  return (
    <form onSubmit={submit(handleSubmit(onSubmit))}>
      <input
        type='search'
        placeholder={'検索ワードを入力'}
        {...register('query')}
      />
      <IconButton
        as='button'
        type='submit'
        disabled={isPending}
        icon={SearchIcon}
        srText={'検索する'}
      />
    </form>
  );
}

その中で注目したいのは、onSubmit 関数です。

const onSubmit = ({ query }: SearchFormSchema) => {
  const serialize = createSerializer({
    query: parseAsString.withOptions({
      startTransition,
    }),
  });
  const existingSearchParams = new URLSearchParams(
    pathname.includes('/search') ? searchParams : undefined
  );
  const queryParameter = serialize(existingSearchParams, { query });
  router.push(`/search${queryParameter}`);
};

前のセクションで説明したように、クエリパラメータの生成には createSerializer を使用してクエリ文字列を生成しています。

さらに、withOptions を通じて React.startTransition を渡すことができます。これにより、onSubmit 関数内で明示的に startTransition でラップする必要がなくなり、コードがよりシンプルになります。

/search以外のページから検索ページへ遷移する際には、既存のクエリパラメータを引き継がないようにするには、現在のパスに/searchが含まれない場合は URLSearchParams には undefined を渡しています。

const existingSearchParams = new URLSearchParams(
  pathname.includes('/search') ? searchParams : undefined
);

まとめ 📌

この記事では、Next.js での URL 状態管理におけるアプローチとして、nuqs の活用方法を解説しました。

多くのプロジェクトでは、URLSearchParams を使用してクリエリパラメータを直接操作する方法が一般的で、set、append、delete メソッド等を駆使して、複雑なロジックをゴリゴリと書かれているケースもあると思います。

筆者自身、実際のプロジェクトで nuqs を導入してみて、その良さを実感することができました。nuqs を使用することで、これまでの煩雑な操作から解放され、より直感的かつシンプルな URL 状態管理が可能になりました。コードの可読性が向上し、バグの発生リスクも低減できたと感じています。

以上です!

chot Inc. tech blog

Discussion