open atlas
↑ К треку
Паттерны React RXP · 05 · 03

URL как состояние

Помести разделяемое навигационное состояние — фильтры, активную вкладку, страницу, поисковый запрос — в URL через параметры поиска вместо useState: оно переживёт обновление, станет ссылкой и получит назад/вперёд бесплатно; временное частое UI-состояние держи вне URL.

RXP Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Пользователь отфильтровал список товаров до «обувь, дешевле $50, по рейтингу», нашёл идеальную пару и отправил ссылку другу. Друг открывает её — и видит неотфильтрованную сетку. Фильтры исчезли при отправке ссылки — и исчезли бы снова при обновлении, а кнопка «Назад» в браузере не делает ничего полезного. Состояние было настоящим; оно просто жило в useState, который приватен для одного рендера одной вкладки.

Колокация научила тебя проталкивать состояние вниз — в компонент, которому оно принадлежит. Этот урок проталкивает определённый класс состояния наружу — в URL. Часть состояния — вообще не состояние компонента: это описание того, на что смотрит пользователь, и URL — единственное место, где это описание разделяемо, сохраняемо в закладки и переживает перезагрузку. Понимать, какое состояние сюда относится, — это решение уровня senior.

Цель

После этого урока ты можешь отличить навигационное/разделяемое состояние (фильтры, активную вкладку, номер страницы, поисковый запрос) от временного UI-состояния; читать и писать это состояние через параметры поиска с useSearchParams, считая URL единственным источником истины; объяснить три вещи, которые URL даёт бесплатно, — переживание обновления, возможность ссылки и историю назад/вперёд; и назвать режим отказа от его переиспользования: проталкивание каждого нажатия клавиши или наведения в URL засоряет историю и перерисовывает дерево на каждое изменение.

1

Часть состояния описывает то, на что смотрит пользователь, — это состояние относится в URL, а не в useState. Выбор фильтра, активная вкладка, текущая страница, поисковый запрос: все они отвечают на «какой это вид?». URL существует ровно для того, чтобы закодировать текущий вид как адресуемую, копируемую строку. Помещение этого состояния в useState делает его невидимым для адресной строки — поэтому им нельзя поделиться, сохранить в закладки или восстановить.

// useState: вид приватен для этого рендера. Обнови — и его нет;
// скопируй URL — и получатель увидит другой вид.
const [category, setCategory] = useState("all");
const [sort, setSort] = useState("popular");

Тест: если я скопирую этот URL в другую вкладку, должна ли страница выглядеть так же? Если да, это состояние относится в URL.

2

Читай состояние из параметров поиска и считай 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. Компонент — чистая функция от параметров поиска: его можно обновить и сделать ссылкой, потому что весь его вход — в адресной строке.

3

Записывай состояние, обновляя 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 + эффекты.

4

Режим отказа: проталкивание временного частого состояния в 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.