React と Next.js の型付き URL 状態 — useState のように
useUrlState は、自分自身をクエリ文字列に書き込む React の状態です。オブジェクト、配列、日付は型を保ち、あらゆる状態は共有可能なリンクになり、リロード後も維持され、戻るボタンも動きます。プロバイダーも Suspense 境界もボイラープレートも不要。
- ~2 KB gzip 圧縮
- 依存関係ゼロ
- TypeScript ファースト
- Next.js / react-router / Remix / Astro
- MIT
npm i state-in-urlNext.js App Router での URL 状態管理
state-in-url は Next.js 14、15、16 で型付きの状態をクエリ文字列に保存します:機能ごとに useUrlState hook を1つ、アダプターもプロバイダーも Suspense 境界も不要。このページでは App Router に固有のこと — Server Components、プリレンダリング、レイアウト、履歴 — を扱います。
ライブデモは ホームページにあり、Next.js 16 で動いています。
サーバーページから searchParams を転送する
Server Component のページは searchParams を受け取ります — Next.js 15 からは Promise です。await してそのオブジェクトをクライアントコンポーネントに渡し、そこから hook に渡します。最初のサーバーレンダリングの時点でデフォルトではなく URL の値が表示されるため、ちらつきもハイドレーション警告もありません。
// app/jobs/page.tsx (Server Component)
import { JobsList } from './JobsList';
export default async function Page({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
// A Promise since Next.js 15; a plain object in 14
return <JobsList searchParams={await searchParams} />;
}// app/jobs/JobsList.tsx
'use client';
import { useUrlState } from 'state-in-url/next';
// Outside the component: sharing is keyed by object identity
const JOBS_STATE = { q: '', page: 1, remote: false, tags: [] as string[] };
export function JobsList({ searchParams }: { searchParams: object }) {
const { urlState, setUrl } = useUrlState(JOBS_STATE, { searchParams });
return (
<input
value={urlState.q}
onChange={(ev) => setUrl({ q: ev.target.value, page: 1 })}
/>
);
}Suspense 境界なし、プリレンダリングは維持
このフックは useSearchParams を呼び出しません。そのため、このフックを使うコンポーネントは Suspense でラップする必要がなく、ページがプリレンダリングから除外されることもありません。PPR と cacheComponents も含みます。URL を直接読み取り、その後の変更をすべて追跡します。たとえば、 history.pushState を、その存在を知らないコードから呼ばれた場合も追跡します。 プリレンダリングされたページはそれでもデフォルト値を描画します。ビルド時にはクエリ文字列がないためです — 共有リンクが最初の描画から正しくなければならない場合は、そのルートを動的にレンダリングしてください。
レイアウト:ヘッダーからクエリ文字列をデコードする
サーバーのレイアウトは searchParams を決して受け取りません。proxy.ts(非推奨のエイリアスとして middleware.ts も動きます)でクエリ文字列をリクエストヘッダーにコピーし、レイアウト内で decodeState と同じデフォルト状態オブジェクトを使ってデコードします — 結果はクライアントの urlState とまったく同じ型になります。
// proxy.ts (middleware.ts before Next.js 16)
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
export function proxy(request: NextRequest) {
const sp = (request.url.includes('_next') ? '' : request.url).split('?')[1] ?? '';
const headers = new Headers(request.headers);
headers.set('searchParams', sp);
return NextResponse.next({ request: { headers } });
}// app/jobs/layout.tsx (Server Component)
import { headers } from 'next/headers';
import { decodeState } from 'state-in-url/encodeState';
import { JOBS_STATE } from './jobsState';
export default async function Layout({ children }: { children: React.ReactNode }) {
const sp = (await headers()).get('searchParams') ?? '';
const initial = decodeState(sp, JOBS_STATE); // typed like urlState
return <>{/* use `initial` */}{children}</>;
}履歴、シャロー更新、scroll
setUrl はデフォルトで現在の履歴エントリを置き換えるため、入力しても履歴が積み上がりません。1つ push したいときは replace: false を渡します。更新は History API を通るので、サーバーへの往復も、キー入力ごとの _rsc リクエストもありません。変更のたびにサーバーで再レンダリングさせたい場合は useHistory: false を渡すと、代わりに Next.js のルーターを通ります。scroll はデフォルトで false です。
速い入力:今すぐ描画し、URL は後で書く
テキストフィールドやスライダーでは、変更のたびに setState で更新し、blur 時またはデバウンス後に引数なしで setUrl() を呼びます。コンポーネントは即座に再描画され、URL は内容ベースの差分で一度だけ書き込まれるため、繰り返し呼んでも安全です。
const { urlState, setState, setUrl } = useJobsState();
// Render now, write the URL once the field is left
<input
value={urlState.q}
onChange={(ev) => setState({ q: ev.target.value })}
onBlur={() => setUrl()}
/>
setUrl({ page: 2 }); // replaces the history entry (default)
setUrl({ page: 2 }, { replace: false }); // pushes a new one — Back returns to page 1
setUrl({ page: 2 }, { scroll: true }); // scroll to top, off by defaultNext.js の URL 状態 — よくある質問
- Next.js App Router で状態を URL に保存するには?
- コンポーネントの外でデフォルト状態オブジェクトを定義し、state-in-url/next の useUrlState を小さな hook に包み、その hook を任意のクライアントコンポーネントで呼びます。urlState が型付きの現在の値で、setUrl が partial をクエリ文字列に書き込みます。ページの searchParams prop を渡しておけば、サーバーレンダリングの時点で正しい値になります。
- useSearchParams に Suspense 境界は必要ですか?state-in-url は?
- Next.js の useSearchParams は、静的にレンダリングされるルートを最も近い Suspense 境界までクライアントレンダリングに切り替え、境界がなければビルドが失敗します。state-in-url はそれを決して呼びません:サーバーでは searchParams を、クライアントでは window.location を読むため、境界は不要で、PPR を含むプリレンダリングが維持されます。
- Server Component で URL 状態を読むには?
- ページは searchParams prop として受け取ります — await して、クライアントの hook に転送するか、decodeState と同じデフォルトオブジェクトでサーバー側でデコードします。レイアウトは searchParams を受け取らないので、proxy.ts で設定したヘッダー経由でクエリ文字列を公開し、それをデコードしてください。
- URL を更新するとサーバーでページが再レンダリングされますか?
- デフォルトでは再レンダリングされません。setUrl は History API で更新するため、何もフェッチされず、_rsc リクエストも発生しません。サーバーに新しい状態を見せたいとき — たとえば Server Component でリストを再取得するとき — は useHistory: false を渡すと、更新が Next.js のルーターを通り、ルートが再レンダリングされます。
- state-in-url は Next.js における nuqs の代替になりますか?
- はい。どちらも型付きの状態をクエリ文字列に保存します。state-in-url はネストした値と日付を保持したままオブジェクト1つを受け取り、アダプターコンポーネントもキーごとのパーサーも不要で、useSearchParams に決して触れません。値ごとに人が読めるクエリパラメータにしたいなら nuqs のほうが向いています。完全版の比較をご覧ください。
- どの Next.js バージョンに対応していますか?
- App Router の Next.js 14、15、16 に対応し、15 で導入された非同期の searchParams と、16 の PPR 付き cacheComponents も含みます。それ以外の構成では、フレームワークに依存しない encodeState と decodeState ヘルパーを好きなルーターと組み合わせて使えます。
