実践例で考える nuqs を使った Next.js の URL パラメータ管理
はじめに 🚩
Next.js を例に、URL 状態管理には以下のような課題があると思います。
- JavaScript の Web API である URLSearchParams を使用してクエリパラメータの直接操作が必要になり、ロジックが煩雑になりがちである
- 型安全性が確保しづらく、バグの原因になることがある
- サーバーコンポーネントとクライアントコンポーネントで一貫した方法でクエリパラメータにアクセスすることが難しい
Next.js のタイプセーフな検索パラメータ管理マネージャーである nuqs を使用することで、これらの課題を解決できます。
この記事では、基本的な使い方から実践例までを通して、nuqs の使い方を解説します。
基本的な使い方 📝
まずは、nuqs の基本的な使い方を簡単に説明します。
ボタンのクリックでクエリパラメータを変更する方法
button をクリックした際など、onClick でクエリパラメータを変更する場合は、useQueryState を使用します。
第一引数に状態の名前、第二引数に状態の値を渡し、返り値には React.useState と同じような使用感で状態の値と、その値を変更するための関数を受け取ることができます。
const [query, setQuery] = useQueryState('query', parseAsString);
<button onClick={() => setQuery('hello')}>Set Query</button>;
ボタンをクリックすると、URL のクエリパラメータに ?query=hello
が追加されます。
クエリ文字列のシリアライズ方法
Link(a タグ)コンポーネントの href 属性に状態値を含むクエリ文字列を生成するには、createSerializer ヘルパー関数を使用できます。
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 は文字列だけでなく、数値、真偽値、日付など、様々な型をサポートしています。
詳細については、公式ドキュメントを参照してください。
実践例 📝
以下それぞれのケース別に、実際のコードを交えて解説します。
オブジェクトを使った複数クエリパラメータの管理方法
商品の絞り込み機能を例に考えてみます。検索クエリ、タグ別、ソート順など、複数のクエリパラメータを一括で管理したいケースがよくあります。
このような場合、useQueryStates
を活用することで、複数のクエリパラメータを 1 つのオブジェクトとしてまとめて管理できるようになります。結果として、個別にクエリパラメータを管理する煩雑さを避けることができます。
パーサーの定義
コンポーネント別にパラメータを各々のコンポーネントで使いたいので、同じセグメントの _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);
このように状態管理ライブラリのような使用感で、クエリパラメータを管理して使いたい値を取得したり、更新したりすることができます。
なお、今回の例ではクライアントコンポーネントでクエリパラメータを管理していますが、サーバーコンポーネントでも同様にクエリパラメータの管理が可能です。この柔軟性により適切な場所でクエリパラメータを型安全に扱うことができます。
詳しくはドキュメントを参照してください。
また、関連した複数のクエリパラメータを管理する別の例として、Table コンポーネントがあります。Table コンポーネントでは、ソート順やページネーションなど、複数の状態をクエリパラメータとして管理することが一般的です。
例えば、shadcn-tableという Table コンポーネントを提供するライブラリでは、nuqs を活用して複数のクエリパラメータを効率的に管理していますので、内部コードを覗いてみると参考になるかと思います。
独立したクエリパラメータの管理方法
先程の例のように1つの画面で関連したクエリパラメーターをまとめて管理したいケースもあれば、それぞれのクエリパラメーターが独立しており、干渉し合わずに個別に管理したいケースもあります。
例えば Tabs のようなコンポーネントでは、各 Tab(セクション)ごとに、それぞれのクエリパラメーターを管理したいです。
具体的には以下のようなイメージです。
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
というパスに遷移し、同時に検索ボックスに入力された値をクエリパラメータとして管理したいです。
以下は 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.com)のエンジニアブログです。 フロントエンドエンジニア募集中! カジュアル面接申し込みはこちらから chot-inc.com/recruit/iuj62owig
Discussion