検索フォームやフィルター機能を実装する際、ユーザーの入力を URL パラメータに反映させることは、状態の永続化や共有可能な URL の生成において重要です。しかし、キー入力のたびに URL を更新すると、パフォーマンス問題を引き起こす可能性があります。また、history: 'push'
オプションを使用している場合は履歴の肥大化も発生します。
nuqs v2.5.0 で導入された Debounce 機能は、この問題に対する解決策を提供します。本機能は PR #900 で実装され、limitUrlUpdates
オプションとして提供されています。これにより、開発者は URL 更新のタイミングを細かく制御できるようになりました。
また筆者は過去に nuqs に関する記事を書いています。そちらもあわせてご覧ください。
Debounce と Throttle は開発者がよく混同する概念ですが、それぞれ異なる効果を持つため、適切に使い分けることが重要です。
以下の記事を参考にして、Debounce と Throttle の違いを理解しましょう。
Debounce
Debounce は、連続したイベントが発生した際に、最後のイベントから一定時間経過後に 1 回だけ処理を実行する仕組みです。
作者の Kettanaito 氏は、Debounce を「過負荷のウェイター(overloaded waiter)」に例えています:注文を続けている間、ウェイターはあなたの要求を無視し、注文が止まってから少し時間をおいて最後の注文だけを処理します。
Throttle
Throttle は、一定時間内に最大 1 回だけ処理を実行する仕組みです。
Kettanaito 氏は、Throttle を「バネ仕掛けのボールマシン」に例えています:ボールを投げた後、バネが元に戻るまでの時間が必要で、その間は新しいボールを投げることができません。
Debounce 機能は、limitUrlUpdates
オプションを通じて実現されています。この機能により、開発者は URL 更新のタイミングを細かく制御できるようになります。
2-1. なぜ URL パラメータに Debounce が必要なのか
実際のアプリケーションで発生する問題を具体的に見てみましょう。検索フォームの実装では頻繁な URL 更新が問題になります:
function SearchPage() {
const [query] = useQueryState('q');
return (
div>
input
value={query || ''}
onChange={(e) => setQuery(e.target.value)}
/>
{}
SearchResults query={query} />
div>
);
}
この実装では以下の深刻な問題が発生します:
- API レート制限への抵触: 秒間数十回のリクエストが発生
- ブラウザのパフォーマンス低下: URL 更新処理による CPU 負荷
- ネットワーク帯域の無駄遣い: 不要なリクエストによるトラフィック増加
-
履歴管理の複雑化:
history: 'push'
を使用している場合、大量の履歴エントリが作成される(そのため検索入力にはデフォルトのhistory: 'replace'
を使用すべき)
Throttle では解決できない理由
Kettanaito 氏の記事でも触れられているように、Throttle は「一貫した更新」が必要な場合に適していますが、検索入力では最終的な値のみが重要です:
2-2. nuqs の Debounce/Throttle ソリューション
nuqs v2.5.0 では、limitUrlUpdates
オプションと debounce
/throttle
関数を組み合わせることで、簡単に debounce を実装できます:
import { debounce } from 'nuqs';
const SearchForm: React.FC = () => {
const [search, setSearch] = useQueryState('q', {
limitUrlUpdates: debounce(500),
});
return (
input
value={search || ''}
onChange={(e) => setSearch(e.target.value)}
placeholder='検索...'
/>
);
};
const AdvancedSearchForm: React.FC = () => {
const [search, setSearch] = useQueryState(
'q',
parseAsString.withDefault('').withOptions({
shallow: false
})
);
return (
input
value={search}
onChange={(e) =>
setSearch(e.target.value, {
limitUrlUpdates: e.target.value === '' ? undefined : debounce(500),
})
}
placeholder='検索...'
/>
);
};
import { throttle } from 'nuqs';
const SliderComponent: React.FC = () => {
const [value, setValue] = useQueryState('slider', {
limitUrlUpdates: throttle(100),
});
return (
input
type="range"
value={value || 0}
onChange={(e) => setValue(e.target.value)}
min={0}
max={100}
/>
);
};
内部的な動作メカニズム
nuqs の Debounce/Throttle は、内部的にキューシステムを使用しています。その動作メカニズムを詳しく見てみます。
核心となる最適化技術
1. AbortController による Promise キャンセル制御
従来の setTimeout
+ clearTimeout
パターンではなく、AbortController
を使用してキャンセル可能なタイマーを実現しています:
const timeout = (ms: number, signal?: AbortSignal) => {
return new Promisevoid>((resolve, reject) => {
const timeoutId = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(timeoutId);
reject(new DOMException('Aborted', 'AbortError'));
});
});
};
class DebouncedQueue {
private controller = new AbortController();
push(value: T) {
this.controller.abort();
this.controller = new AbortController();
timeout(delay, this.controller.signal)
.then(() => this.execute())
.catch((error) => {
if (error.name !== 'AbortError') throw error;
});
}
}
利点:
- 確実なキャンセル: Promise ベースでキャンセルが確実に行われる
- メモリリーク防止: コンポーネントのアンマウント時に確実にクリーンアップ
- デバッグ容易性: AbortError により中断理由が明確
2. Deferred パターンによる Promise 制御
Promise.withResolvers()
の代わりに、createDeferred()
という独自実装を使用してPromiseの外部制御を実現しています:
function createDeferredT>() {
let resolve: (value: T) => void;
let reject: (reason?: any) => void;
const promise = new PromiseT>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve: resolve!, reject: reject! };
}
class DebouncedQueue {
push(value: T): PromiseT> {
const deferred = createDeferredT>();
this.promise = deferred.promise;
this.resolve = deferred.resolve;
timeout(delay, this.controller.signal)
.then(() => {
this.callback(value);
this.resolve(value);
});
return this.promise;
}
}
3. パラメータ名ごとの独立したキュー管理
DebounceController
により、各パラメータ名(key)に対して独立したキューを管理します:
class DebounceController {
private readonly queues = new MapKey, DebouncedQueueValue>>();
getOrCreateQueueT>(key: string, callback: (value: T) => void, delay: number) {
const queue = this.queues.get(key) ?? new DebouncedQueue(callback, delay);
this.queues.set(key, queue);
return queue;
}
}
実現される最適化
この設計により、以下の最適化が実現されています:
- 最新値の保持:複数の更新が発生した場合、最新の値のみを処理
- UI の応答性維持:入力フィールドは即座に更新され、URL更新のみが制御される
- 確実なクリーンアップ:AbortController によるメモリリーク防止
- 独立したキュー管理:各 URL パラメータが独立して debounce 制御される
- 型安全性: TypeScript の型推論により実行時エラーを防止
limitUrlUpdates
オプションにより、さまざまなユースケースに対応できるようになりました。ここでは、実際のアプリケーションでよく見られるパターンを紹介します。
3-1. 検索フォームでの実装
手動 debounce の問題点
export function SearchInput({ className }: Props) {
const [{ search }, setSearchParams] = useQueryState(
'search',
parseAsString.withOptions({
clearOnDefault: true,
history: 'replace',
})
);
const [inputValue, setInputValue] = useState(search);
const debouncedSearch = useMemo(
() =>
debounce((value: string) => {
void setSearchParams({ search: value });
}, 500),
[setSearchParams]
);
const handleSearch = (e: React.ChangeEventHTMLInputElement>) => {
const value = e.target.value;
setInputValue(value);
debouncedSearch(value);
};
return (
>
input
type="search"
placeholder="検索ワード"
value={inputValue || ''}
onChange={handleSearch}
/>
{inputValue && (
button
type="button"
onClick={() => {
setInputValue('');
debouncedSearch('');
}}
>
button>
)}
>
);
}
手動 debounce の実装
function debounceT extends (...args: any[]) => void>(
func: T,
delay: number
): (...args: ParametersT>) => void {
let timeoutId: NodeJS.Timeout;
return (...args: ParametersT>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func(...args);
}, delay);
};
}
const handleSearch = debounce((query: string) => {
console.log('検索実行:', query);
}, 500);
この実装には以下の問題がありました:
-
状態の二重管理:
search
とinputValue
を別々に管理する必要がある -
タイマー管理の複雑さ:
useRef
でタイマーを管理し、クリーンアップを意識する必要がある - コード量の増大: debounce ロジックのために多くのボイラープレートコードが必要
- メモリリークの可能性: コンポーネントのアンマウント時にタイマーが残る可能性
またコード内だと他に、コンポーネントのアンマウント時のクリーンアップ処理が必要として、debounce 関数内の timeout をクリアする必要がありますが、一般的な debounce 実装ではこのアクセスが困難だったりと問題があります。
nuqs の Debounce 機能を活用
先程の手動 debounce の実装を nuqs の Debounce 機能を活用して実装してみます。
import { useQueryState, parseAsString, debounce } from 'nuqs';
export function SearchInput({ className }: Props) {
const [search, setSearch] = useQueryState(
'search',
parseAsString.withOptions({
clearOnDefault: true,
history: 'replace',
limitUrlUpdates: debounce(500)
})
);
return (
>
input
type="search"
placeholder="検索ワード"
value={search || ''}
onChange={(e) =>
setSearch(e.target.value || null)
}
/>
{search && (
button
type="button"
onClick={() => setSearch(null)}
>
XIcon className="size-4" />
button>
)}
>
);
}
これにより、手動の debounce ロジックが不要になりコード量が削減され、inputValue
の別管理が不要で状態管理が簡素化され、nuqs の型システムを活用することで型安全性が向上することです。
3-2. 複数フィルターでの最適化
複数のフィルター条件を扱う場合、それぞれに適切な debounce 時間を設定できます:
import { useQueryStates, parseAsString, parseAsInteger, debounce } from 'nuqs';
export const filterParsers = {
search: parseAsString,
category: parseAsString,
minPrice: parseAsInteger,
maxPrice: parseAsInteger,
sortBy: parseAsString,
};
export function ProductFilters() {
const [filters, setFilters] = useQueryStates(filterParsers, {
history: 'push',
clearOnDefault: true,
});
return (
div className='filters-container'>
{}
input
type='search'
value={filters.search || ''}
onChange={(e) =>
setFilters(
{ search: e.target.value || null },
{ limitUrlUpdates: debounce(500) }
)
}
placeholder='商品を検索...'
/>
{}
select
value={filters.category || ''}
onChange={(e) => setFilters({ category: e.target.value || null })}
>
option value=''>すべてのカテゴリoption>
option value='categoryA'>カテゴリAoption>
option value='categoryB'>カテゴリBoption>
select>
{}
div className='price-range'>
input
type='number'
value={filters.minPrice || ''}
onChange={(e) =>
setFilters(
{ minPrice: e.target.value ? parseInt(e.target.value) : null },
{ limitUrlUpdates: debounce(1000) }
)
}
placeholder='最低価格'
/>
span>〜span>
input
type='number'
value={filters.maxPrice || ''}
onChange={(e) =>
setFilters(
{ maxPrice: e.target.value ? parseInt(e.target.value) : null },
{ limitUrlUpdates: debounce(1000) }
)
}
placeholder='最高価格'
/>
div>
div>
);
}
4-1. DebounceとThrottleの使用指針
実践的な判断基準
- 「最後の状態だけが重要」ならDebounce(検索、バリデーション、リサイズ完了後の処理)
- 「継続的なフィードバックが必要」ならThrottle(スクロール、マウス移動、リアルタイム更新)
- 「単純なUI状態変更」なら適用不要(チェックボックス、基本的なトグル)
用途別推奨
推奨手法 | 遅延時間 | 用途 | 効果・理由 |
---|---|---|---|
Debounce | 200-300ms | 検索・オートコンプリート | • API呼び出しを最大90%削減 • タイピング完了を待つ |
Throttle | 100-250ms | 価格フィルター・スライダー | • リアルタイムフィードバック • パフォーマンスのバランス |
Debounce | 400-500ms | リアルタイムバリデーション | • 入力中のエラー表示を防止 • ユーザー体験向上 |
通常不要 | 100-200ms(必要時) | チェックボックス・トグル | • 単純なUI変更 • 即座の反応が期待される |
Throttle | 200ms推奨 | 無限スクロール | • 一定間隔でスクロール位置をチェック • 過度な処理防止 |
Debounce | 200-450ms | ウィンドウリサイズ | • レイアウト再計算は完了後に一度だけ • パフォーマンス向上 |
Throttle | 16ms/100ms | スクロールイベント | • 滑らかなアニメーション • 適度な応答性 |
Throttle | 16-50ms | マウス移動・ホバー | • 滑らかな追従 • 過度なイベント発火防止 |
参考資料
4-2. Transitions との統合
shallow: false
と組み合わせることで、React の useTransition
フックを使用して、サーバーが URL の更新でサーバーコンポーネントを再レンダリングしている間のローディング状態を取得できます:
'use client';
import { useTransition } from 'react';
import { useQueryState, parseAsString } from 'nuqs';
function ClientComponent({ data }) {
const [isLoading, startTransition] = useTransition();
const [query, setQuery] = useQueryState(
'query',
parseAsString().withOptions({ startTransition, shallow: false })
);
if (isLoading) return div>Loading...div>;
return div>...div>;
}
debounce と startTransition の組み合わせ
debounce と startTransition を同時に使用することも可能です:
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useQueryState(
'query',
parseAsString().withOptions({
startTransition,
shallow: false
})
);
const handleSearch = (value: string) => {
setQuery(value, {
limitUrlUpdates: debounce(500)
});
};
>
input
value={localValue}
onChange={(e) => handleSearch(e.target.value)}
className={clsx(
'search-input',
isPending && 'border-blue-300 bg-blue-50'
)}
/>
{isPending && (
div className="text-sm text-blue-600 mt-1">
検索中...
div>
)}
>
4-3. 履歴管理の最適化戦略
デフォルトでは、状態更新は現在の履歴エントリを置き換え(history: 'replace'
)ます。これは git squash
のようなもので、すべての状態変更が単一の履歴エントリにマージされます。
各状態変更で新しい履歴エントリを追加(history: 'push'
)することもできます:
export function useOptimizedSearch() {
const [instantSearch, setInstantSearch] = useQueryState('instant', {
limitUrlUpdates: debounce(300),
history: 'replace',
shallow: false,
scroll: false,
});
const [importantSearch, setImportantSearch] = useQueryState('important', {
limitUrlUpdates: debounce(1000),
history: 'push',
shallow: false,
scroll: true,
});
const executeSearch = (query: string) => {
if (query.length >= 3) {
setImportantSearch(query);
} else {
setInstantSearch(query);
}
};
return { instantSearch, importantSearch, executeSearch };
}
⚠️ 注意: ブラウザの戻るボタンを乱用すると、UX が損なわれる可能性があります。history: 'push'
は、タブやモーダルなどのナビゲーション的な体験に寄与するパラメータのみに使用することを推奨します。
本記事では、nuqs v2.5.0 で導入された Debounce 機能について解説しました。
nuqs の Debounce 機能は、特にlimitUrlUpdates
オプションによって、これらの問題を効果的に解決します。debounce()
やthrottle()
関数を使った制御により、URL 更新のタイミングを細かく制御できるようになりました。
以上です!
Views: 0