Типизированное состояние в URL для React и Next.js — как useState
useUrlState — это состояние React, которое само записывает себя в строку запроса. Объекты, массивы и даты сохраняют свои типы, любое состояние — это ссылка, которой можно поделиться, оно переживает перезагрузку, и кнопка «Назад» работает. Без провайдеров, без границы Suspense, без бойлерплейта.
- ~2 KB в gzip
- ноль зависимостей
- TypeScript-first
- Next.js / react-router / Remix / Astro
- MIT
npm i state-in-urlУправление состоянием в URL в Next.js App Router
state-in-url хранит типизированное состояние в строке запроса на Next.js 14, 15 и 16: один hook useUrlState на фичу, без адаптера, без провайдера, без границы Suspense. Эта страница — о том, что специфично для App Router: Server Components, пререндеринг, layout и история.
Живое демо на главной странице работает на Next.js 16.
Пробрасывайте searchParams из серверной страницы
Серверная страница (Server Component) получает searchParams — начиная с Next.js 15 это Promise. Дождитесь его и передайте объект в клиентский компонент, а тот отдаст его в 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, с сохранением пререндеринга
Этот hook никогда не вызывает useSearchParams, поэтому использующему его компоненту не нужна обёртка в Suspense и он не исключает страницу из предварительного рендеринга — включая PPR и cacheComponents . Он читает URL напрямую и отслеживает каждое последующее изменение, включая history.pushState из кода, который о нём ничего не знает. Пререндеренная страница всё равно рендерит значения по умолчанию, потому что на этапе сборки строки запроса нет — рендерьте роут динамически, когда ссылка, которой поделились, должна быть правильной уже при первой отрисовке.
Layout: декодируйте строку запроса из заголовка
Серверные layout никогда не получают searchParams. Скопируйте строку запроса в заголовок запроса в proxy.ts (middleware.ts по-прежнему работает как устаревший алиас) и декодируйте её в layout через 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}</>;
}История, shallow-обновления и scroll
setUrl по умолчанию заменяет текущую запись истории, поэтому ввод текста не плодит записи; передайте replace: false, чтобы добавить новую. Обновления идут через History API — без обращения к серверу и без запроса _rsc на каждое нажатие клавиши. Передайте useHistory: false, чтобы вместо этого идти через роутер Next.js, когда сервер должен перерендериваться на каждое изменение. scroll по умолчанию равен false.
Быстрые поля ввода: рендерьте сейчас, пишите в URL потом
Для текстовых полей и слайдеров обновляйте состояние через setState на каждое изменение и вызывайте setUrl() без аргументов по blur или после debounce. Компонент перерендеривается сразу; 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 defaultЧитайте полное сравнение — одна фича в обеих библиотеках и как мигрировать
Состояние в URL в Next.js — частые вопросы
- Как хранить состояние в URL в Next.js App Router?
- Определите объект состояния по умолчанию вне компонента, оберните useUrlState из state-in-url/next в небольшой hook и вызывайте этот hook в любом клиентском компоненте. urlState — типизированное текущее значение, setUrl записывает partial в строку запроса. Передайте туда проп searchParams страницы, чтобы серверный рендер был правильным сразу.
- Нужна ли useSearchParams граница Suspense, и нужна ли она state-in-url?
- useSearchParams в Next.js переводит статически рендеримый роут в клиентский рендеринг до ближайшей границы Suspense, а без неё сборка падает. state-in-url его никогда не вызывает: он читает searchParams на сервере и window.location на клиенте, поэтому граница не нужна, а пререндеринг, включая PPR, сохраняется.
- Как прочитать состояние из URL в Server Component?
- Страницы получают его через проп searchParams — дождитесь его и либо пробросьте в клиентский hook, либо декодируйте на сервере через decodeState с тем же объектом по умолчанию. Layout не получают searchParams; вынесите строку запроса в заголовок, выставленный в proxy.ts, и декодируйте уже его.
- Перерендеривает ли обновление URL страницу на сервере?
- По умолчанию нет. setUrl обновляет через History API, поэтому ничего не запрашивается и запрос _rsc не уходит. Когда сервер должен увидеть новое состояние — скажем, чтобы перезапросить список в Server Component — передайте useHistory: false, и обновления пойдут через роутер Next.js, а роут перерендерится.
- Является ли state-in-url альтернативой nuqs для Next.js?
- Да. Обе библиотеки хранят типизированное состояние в строке запроса; state-in-url берёт один объект, сохраняя вложенные значения и даты, не требует компонента-адаптера и парсера на каждый ключ и никогда не трогает useSearchParams. nuqs подходит лучше, когда каждое значение должно быть отдельным читаемым query-параметром. Смотрите полное сравнение.
- Какие версии Next.js поддерживаются?
- Next.js 14, 15 и 16 на App Router, включая асинхронный searchParams, появившийся в 15, и cacheComponents с PPR в 16. В других конфигурациях можно использовать не зависящие от фреймворка хелперы encodeState и decodeState с любым роутером.
