この記事はBrainpad Advent Calendar 2025の13日目になります。
株式会社ブレインパッド プロダクトユニットの角田です。
弊社は「データ活用の促進を通じて持続可能な未来をつくる」をミッションに、
データ分析支援やSaaSプロダクトの提供を通じて、企業の「データ活用の日常化」を推進しております。
業務では、LLMエージェント等の技術を使った新規事業開発を担当しております。
はじめに
最近、クエリパラメータの状態管理ライブラリのNuqsが話題になっています。
今回はデータテーブルとLLM検索を組み合わせたUIで使えるか試してみました。
Nuqsを使うと「LLMが返したフィルターJSONをそのままUIに適用する」実装がシンプルに書けます。フィルター状態をNuqsのURL stateで一元管理することで、UIとLLMが同じデータを見るようになります。
Nuqsとは
Next.js / React向けのクエリパラメータの状態管理ライブラリです。URLのクエリ文字列をReactのstateのように扱えます。
今回は基本的な使い方のみを簡単に説明します。
基本的な使い方
import { useQueryState, parseAsString } from 'nuqs';
const [name, setName] = useQueryState('name', parseAsString.withDefault(''));
useStateと同じインターフェースで、値の変更が自動的にURLに反映されます。setName('alice')を呼ぶと、URLが?name=aliceに変わります。
複数のパラメータをまとめて管理する
複数のクエリパラメータを扱う場合はuseQueryStatesを使います。
import { useQueryStates, parseAsString, parseAsInteger } from 'nuqs';
const [filters, setFilters] = useQueryStates({
q: parseAsString.withDefault(''),
page: parseAsInteger.withDefault(1),
});
// filters.q, filters.page でアクセス
// setFilters({ q: 'test', page: 2 }) でまとめて更新
型安全なパーサー
Nuqsは様々な型のパーサーを提供しています。
-
parseAsString- 文字列 -
parseAsInteger- 整数 -
parseAsBoolean- 真偽値 -
parseAsArrayOf- 配列 -
parseAsJson- JSONオブジェクト
URLの文字列から適切な型への変換と、その逆変換が自動で行われます。
今回作ったもの
検証用に作ったのは、データテーブルとLLM検索を組み合わせたUIのデモです。
ユーザーが「エンタープライズ顧客に絞って」と入力すると、LLMがフィルター条件を返し、それをUIに反映します。
フィルター済みの状態から条件を追加することもできます。
Nuqsなしで実装すると何が辛いか
ローカルstate + 手書きURL管理だと、フィルター状態が3箇所に分散します。
また、UI state更新後に、URLも手動で更新する必要があります。
| 管理対象 | 形式 | 更新タイミング |
|---|---|---|
| UI state | Reactのstate | ユーザー操作時 |
| URLクエリ | 文字列 | 手動で同期 |
| LLM入出力 | JSONオブジェクト | API呼び出し時に変換 |
別々に管理していると、
- UI state更新後に、URLも手動で更新する必要がある
- LLMに渡す
filtersの形と、URLのクエリの形と、UIのstateがズレやすい - フィルターを増やすたびに URL ↔ state ↔ LLM入力の変換コードが増える
のような問題が起こりがちです。
Nuqsでフィルターを一元管理する
Nuqsを使うと、フィルター状態を一元管理できるようになります。
まず、フィルターの型を定義します。
type TableFilters = {
q: string;
status: Status[];
segment: Segment[];
owner: string;
from: string;
to: string;
sort: "arr-desc" | "arr-asc" | "date-desc" | "date-asc";
page: number;
};
この型をNuqsで使います。
import { useQueryStates, parseAsString, parseAsArrayOf, parseAsInteger } from 'nuqs';
const [filters, setFilters] = useQueryStates({
q: parseAsString.withDefault(""),
status: parseAsArrayOf<Status>(parseAsString).withDefault([]),
segment: parseAsArrayOf<Segment>(parseAsString).withDefault([]),
owner: parseAsString.withDefault(""),
from: parseAsString.withDefault(""),
to: parseAsString.withDefault(""),
sort: parseAsString.withDefault("arr-desc"),
page: parseAsInteger.withDefault(1),
});
LLMのレスポンス型も同じTableFiltersを参照します。
type LlmResponse = {
filters: Partial<TableFilters>;
explanation: string;
};
UIもLLMも同じTableFiltersを見るので、ズレが起きません。
| Before | After |
|---|---|
| UIは複数のstateを見る | UIはfiltersを見るだけ |
| URL同期は手動管理 |
setFilters()を呼ぶだけでURLは自動更新 |
| LLM用に別途変換が必要 | LLMに渡すfiltersの形 = Nuqsのスキーマ |
LLMが返したフィルターをそのまま適用する
LLMから返ってくるフィルター条件を、setFiltersでマージします。
const result = await simulateLlm(prompt, filters);
setFilters((prev) => ({
...prev,
...result.filters,
page: 1,
}));
これだけで、
- URLがLLM提案どおりの条件に変わる
- テーブルも同じ条件でフィルタされる
- 次にLLMに聞くときの
filtersも揃う
「LLM → UI反映」がNuqsのstate更新1ステップで済みます。
動作イメージ
ユーザー: 「先月の未対応案件を見せて」
↓
LLM: { status: ["pending"], from: "2024-11-01", to: "2024-11-30" }
↓
setFilters(result.filters)
↓
URL: ?status=pending&from=2024-11-01&to=2024-11-30
↓
テーブル: フィルタ適用済みで表示
コード比較
Nuqsなし
const llmFilters = result.filters;
// 1. UI stateに反映
setStatusState(llmFilters.status);
setFromState(llmFilters.from);
setToState(llmFilters.to);
// 2. URLにも反映
const params = new URLSearchParams();
params.set('status', llmFilters.status.join(','));
params.set('from', llmFilters.from);
params.set('to', llmFilters.to);
router.push(`?${params.toString()}`);
// 3. 次回LLM呼び出し用のオブジェクトも更新
setLlmInputFilters({
status: llmFilters.status,
from: llmFilters.from,
to: llmFilters.to,
});
変換レイヤーが増え、バグの温床になります。
Nuqsあり
setFilters(result.filters);
人間の操作も、LLMの提案も、setFiltersに流すだけです。
まとめ
今回、Nuqsを使って人間操作とLLM提案が同じインターフェースで簡単に書けることを紹介しました。
Nuqsには複雑なクエリパラメータを型安全に扱う機能が他にもあります。Zodスキーマとの統合や、配列・JSONのパースなど、今回紹介した以外にも便利な機能があるので、試してみてください。
