URL как состояние
Помести разделяемое навигационное состояние — фильтры, активную вкладку, страницу, поисковый запрос — в URL через параметры поиска вместо useState: оно переживёт обновление, станет ссылкой и получит назад/вперёд бесплатно; временное частое UI-состояние держи вне URL.
Пользователь отфильтровал список товаров до «обувь, дешевле $50, по рейтингу», нашёл идеальную пару и отправил ссылку другу. Друг открывает её — и видит неотфильтрованную сетку. Фильтры исчезли при отправке ссылки — и исчезли бы снова при обновлении, а кнопка «Назад» в браузере не делает ничего полезного. Состояние было настоящим; оно просто жило в useState, который приватен для одного рендера одной вкладки.
Колокация научила тебя проталкивать состояние вниз — в компонент, которому оно принадлежит. Этот урок проталкивает определённый класс состояния наружу — в URL. Часть состояния — вообще не состояние компонента: это описание того, на что смотрит пользователь, и URL — единственное место, где это описание разделяемо, сохраняемо в закладки и переживает перезагрузку. Понимать, какое состояние сюда относится, — это решение уровня senior.
После этого урока ты можешь отличить навигационное/разделяемое состояние (фильтры, активную вкладку, номер страницы, поисковый запрос) от временного UI-состояния; читать и писать это состояние через параметры поиска с useSearchParams, считая URL единственным источником истины; объяснить три вещи, которые URL даёт бесплатно, — переживание обновления, возможность ссылки и историю назад/вперёд; и назвать режим отказа от его переиспользования: проталкивание каждого нажатия клавиши или наведения в URL засоряет историю и перерисовывает дерево на каждое изменение.
Часть состояния описывает то, на что смотрит пользователь, — это состояние относится в URL, а не в useState. Выбор фильтра, активная вкладка, текущая страница, поисковый запрос: все они отвечают на «какой это вид?». URL существует ровно для того, чтобы закодировать текущий вид как адресуемую, копируемую строку. Помещение этого состояния в useState делает его невидимым для адресной строки — поэтому им нельзя поделиться, сохранить в закладки или восстановить.
// useState: вид приватен для этого рендера. Обнови — и его нет;
// скопируй URL — и получатель увидит другой вид.
const [category, setCategory] = useState("all");
const [sort, setSort] = useState("popular");Тест: если я скопирую этот URL в другую вкладку, должна ли страница выглядеть так же? Если да, это состояние относится в URL.
Читай состояние из параметров поиска и считай URL источником истины — а не копией useState. В App Router useSearchParams возвращает доступный только для чтения URLSearchParams; ты выводишь свой вид напрямую из него. Нет второго useState, зеркалящего значение, поэтому нечего держать в синхронизации.
"use client";
import { useSearchParams } from "next/navigation";
function ProductList() {
const params = useSearchParams();
const category = params.get("category") ?? "all"; // URL — источник
const sort = params.get("sort") ?? "popular";
return <Grid items={query(category, sort)} />; // UI = f(состояние URL)
}Заметь форму: UI = f(state) по-прежнему держится, но state теперь живёт в URL. Компонент — чистая функция от параметров поиска: его можно обновить и сделать ссылкой, потому что весь его вход — в адресной строке.
Записывай состояние, обновляя URL, — это навигация, — так что назад/вперёд и обновление приходят бесплатно. Чтобы сменить фильтр, ты проталкиваешь новый URL; роутер перерисовывает страницу с новыми параметрами поиска, а браузер записывает запись в историю. Ты никогда не вызываешь setState для этого значения. Строй следующую строку запроса из текущих параметров, чтобы изменить один ключ, не затирая остальные.
"use client";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
function CategoryFilter() {
const params = useSearchParams();
const router = useRouter();
const pathname = usePathname();
function setCategory(next: string) {
const q = new URLSearchParams(params); // начни с текущего URL
q.set("category", next); // измени один ключ
router.push(`${pathname}?${q}`); // навигация, а не setState
}
return <Select value={params.get("category") ?? "all"} onChange={setCategory} />;
}Поскольку каждое изменение — настоящая навигация, кнопка «Назад» переходит к предыдущему фильтру, а перезагрузка восстанавливает точный вид — поведение, которое иначе пришлось бы пересобирать руками через useState + localStorage + эффекты.
Режим отказа: проталкивание временного частого состояния в URL засоряет историю и перерисовывает на каждое изменение. URL — правильный дом для зафиксированного, низкочастотного состояния вида. Он — неправильный дом для значения поля ввода на каждое нажатие клавиши, наведения, наполовину открытого выпадающего списка или позиции перетаскивания. Каждый router.push — навигация: он добавляет запись в историю (так один поиск «обувь» становится пятью нажатиями «Назад» — о, об, обу, обув, обувь) и перерисовывает поддерево маршрута на каждое изменение.
// НЕВЕРНО: каждое нажатие клавиши — навигация. История теперь мусор, и
// весь маршрут перерисовывается на каждый символ.
function Search() {
const router = useRouter(), pathname = usePathname();
return <input onChange={(e) => router.push(`${pathname}?q=${e.target.value}`)} />;
}Исправление — разделить хранилище по частоте: держи живое значение ввода в локальном useState (временное, быстрое, приватное), а фиксируй в URL только на debounce или при сабмите — и используй router.replace (без записи в историю) вместо push для инкрементальных фиксаций. URL хранит зафиксированный запрос; useState хранит нажатия клавиш.
Фильтр товаров перенесён из useState в URL — получив ссылку, обновление и «Назад» бесплатно. Список фильтруется по категории и порядку сортировки.
До — фильтр живёт в useState, поэтому вид приватен и эфемерен:
"use client";
function ProductList() {
const [category, setCategory] = useState("all");
const [sort, setSort] = useState("popular");
const items = query(category, sort);
return (
<>
<Select value={category} onChange={setCategory} options={CATEGORIES} />
<Select value={sort} onChange={setSort} options={SORTS} />
<Grid items={items} />
</>
);
}Это работает, но обновление сбрасывает его к all/popular, URL никогда не меняется, поэтому ссылкой нельзя поделиться, а «Назад» не отменяет фильтр. Состояние навигационное — оно описывает вид — поэтому оно относится в URL.
После — URL является источником истины; чтения приходят из useSearchParams, записи — это навигации:
"use client";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
function ProductList() {
const params = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const category = params.get("category") ?? "all";
const sort = params.get("sort") ?? "popular";
function update(key: string, value: string) {
const q = new URLSearchParams(params); // сохрани остальные ключи
q.set(key, value);
router.push(`${pathname}?${q}`); // навигация → история + разделяемость
}
return (
<>
<Select value={category} onChange={(v) => update("category", v)} options={CATEGORIES} />
<Select value={sort} onChange={(v) => update("sort", v)} options={SORTS} />
<Grid items={query(category, sort)} />
</>
);
}Больше нет useState для этих значений и нечего держать в синхронизации — компонент является чистой функцией от параметров поиска. Теперь /products?category=shoes&sort=rating — разделяемая, сохраняемая в закладки ссылка; обновление восстанавливает точный фильтр; а «Назад» проводит пользователя по его предыдущим выборам. Важно, что текстовое поле поиска (значение частых нажатий клавиш) не попадает в этот URL на каждое нажатие — оно остаётся в локальном состоянии и фиксируется через router.replace на debounce. Зафиксированное состояние вида — в URL; временный набор текста — в useState.
▸Почему это работает
Почему URL — правильный дом для разделяемого состояния, структурно? Потому что URL — единственная часть состояния приложения, которую браузер сам сохраняет, сериализует и по которой навигирует. Помести туда фильтр — и получишь три вещи, которые иначе пришлось бы собирать руками: переживание обновления (адресная строка перезагружается вместе со страницей), возможность ссылки (всё состояние — это копируемая строка) и история (каждое изменение — запись, так что назад/вперёд просто работают). Воспроизвести это с useState означает прокинуть localStorage для персистентности, сериализатор для разделения и ручной стек истории для отмены — три подсистемы, которые платформа уже даёт бесплатно, когда состояние живёт в URL. Это не просто «место для хранения состояния»; это бесплатная персистентность и история.
▸Частая ошибка
Характерный режим отказа — помещение временного или частого состояния в URL — проталкивание каждого нажатия клавиши поля поиска, каждого наведения, каждого пикселя перетаскивания как навигации. Бьют сразу две цены: стек истории заполняется мусорными записями (набор «обувь» оставляет пять нажатий «Назад», чтобы выбраться из одного поиска), и каждый router.push перерисовывает поддерево маршрута, поэтому быстрый ввод ощущается тормозящим. Правило — фиксируй, не транслируй: URL хранит зафиксированное значение (при сабмите или с debounce через router.replace, чтобы не добавлять запись в историю), а живые нажатия клавиш остаются в локальном useState. Противоположная ошибка не менее частая — оставить по-настоящему разделяемое состояние (активную вкладку, номер страницы) запертым в useState, где на него нельзя сослаться или восстановить. Подбирай хранилище под природу состояния: зафиксированное и разделяемое → URL; временное и приватное → локальное состояние.
Поле поиска должно позволять пользователям делиться ссылкой на свои результаты, но набор текста сейчас вызывает router.push(?q=...) на каждое нажатие клавиши — история затоплена, а ввод тормозит. Каково senior-исправление?
Часть состояния — вообще не состояние компонента: оно описывает вид: фильтры, активную вкладку, номер страницы, поисковый запрос. Это навигационное, разделяемое состояние относится в URL, а не в useState, потому что URL — единственное место, где вид адресуем: он переживает обновление, на него можно сослаться/сохранить в закладки, и каждое изменение — запись в истории, поэтому назад/вперёд работают бесплатно. В App Router ты читаешь это состояние из useSearchParams (URL — единственный источник истины, без зеркального useState для синхронизации) и записываешь его, строя новую строку запроса из текущих параметров и вызывая router.push — навигацию, а не setState. Режим отказа — переиспользование паттерна: проталкивание временного, частого состояния (каждое нажатие клавиши, наведение, перетаскивание) в URL засоряет стек истории и перерисовывает маршрут на каждое изменение. Дисциплина — фиксируй, не транслируй: держи живое значение в локальном состоянии и фиксируй в URL на debounce или при сабмите (с router.replace для инкрементальных фиксаций). Подбирай хранилище под природу: зафиксированное и разделяемое идёт в URL; временное и приватное остаётся в useState.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.