← назад к разделу

Экран со списком: фильтр по статусу, поиск по названию, номер страницы. Первое решение, которое приходит в голову, — сложить всё в useState:

function OrdersPage() {
  const [status, setStatus] = useState("all");
  const [query, setQuery] = useState("");
  const [page, setPage] = useState(1);
  // ...
}

Работает — ровно до трёх обычных ситуаций. Пользователь настроил фильтр и обновил страницу: всё сбросилось. Захотел показать коллеге найденное и скопировал адрес: коллега открыл пустой список. Нажал «назад» в браузере после перехода в карточку: вернулся к списку без фильтров и на первую страницу.

Причина одна: состояние экрана живёт в памяти вкладки, а адрес про него ничего не знает.

Что означает состояние в адресе

Тот же экран, но фильтры записаны в адресную строку:

/orders?status=paid&query=мышь&page=3

Теперь адрес полностью описывает, что человек видит. Из этого сразу следует всё, чего не хватало: обновление страницы сохраняет вид, ссылку можно переслать, кнопки «назад» и «вперёд» работают, вкладку можно положить в закладки.

Правило, по которому легко принимать решение:

Если по этому состоянию должно быть возможно вернуться или поделиться — оно в адресе.

Как разобрать параметры и не получить мусор

Адресная строка — это текст, который приходит снаружи. Его правит пользователь, его собирают старые ссылки, его подставляют боты. Поэтому параметры разбирают схемой, а не читают напрямую:

import { z } from "zod";

export const ordersSearchSchema = z.object({
  status: z.enum(["all", "new", "paid", "shipped"]).default("all"),
  query: z.string().trim().max(100).default(""),
  page: z.coerce.number().int().min(1).max(500).default(1),
});

export type OrdersSearch = z.infer<typeof ordersSearchSchema>;

Схема делает три вещи одновременно. Приводит типы: page в адресе — строка, а в коде нужно число. Подставляет значения по умолчанию: пустой адрес /orders даёт тот же объект, что и полный. И отсекает мусор: ?page=-5 или ?status=hacked не доедут до запроса.

Дальше состояние читается и пишется как обычные данные:

function OrdersPage() {
  const { status, query, page } = useOrdersSearch();     // разобрано схемой
  const navigate = useNavigate();

  const setStatus = (next: OrdersSearch["status"]) =>
    navigate({ search: (prev) => ({ ...prev, status: next, page: 1 }) });

  const { data } = useOrders({ status, query, page });
  // ...
}

Обрати внимание на page: 1 при смене фильтра. Это типичная ошибка: человек стоял на третьей странице, поменял фильтр — и попал в пустоту, потому что в новой выборке три страницы не набралось. Сброс страницы при смене условий — часть логики экрана, и она видна прямо здесь.

Замена адреса или новая запись в истории

У навигации есть тонкость, которую замечают не сразу. Каждое изменение адреса по умолчанию добавляет запись в историю браузера. Для фильтра, который меняют кнопками, это нормально: «назад» вернёт предыдущий фильтр.

А вот для поля поиска, которое пишет в адрес на каждый символ, — беда: после ввода слова «беспроводная» кнопку «назад» придётся нажать двенадцать раз. Поэтому частые изменения записывают заменой текущей записи:

navigate({ search: { ...prev, query: value }, replace: true });

И отдельно: в адрес пишут не каждое нажатие клавиши, а с задержкой — иначе на каждый символ полетит запрос к серверу.

Что в адрес не кладут

Адресная строка видна всем: она попадает в историю браузера, в журналы сервера и прокси, в реферер при переходе на другой сайт, в закладки, в скриншот. Отсюда простые запреты:

  • Токены, ключи, пароли — никогда. Это не гипотетическая утечка: адрес уедет в журнал первой же прокси на пути.
  • Персональные данные — тоже: телефон или почта в адресе окажутся в чужих журналах.
  • Черновики форм — они длинные, их неудобно кодировать и незачем присылать другому.

Есть и техническое ограничение: адрес не бесконечный. Разумно держаться пары сотен символов; списки из сорока идентификаторов в параметре — признак, что состояние выбрано не то.

Как разложить состояние по местам

Собираем всё вместе. У экрана обычно четыре вида состояния, и у каждого своё место:

чтогде живётпример
данные с серверакеш запросов (TanStack Query)список заказов, карточка товара
состояние экрана, которым делятсяадресная строкафильтр, поиск, номер страницы, сортировка
состояние интерфейса, переживающее перезагрузкустор с сохранением (Zustand + persist)свёрнутое меню, выбранная тема, плотность таблицы
всё остальное локальноеuseStateоткрыт ли выпадающий список, текст в поле до отправки

Проверочные вопросы, чтобы не гадать:

  • Значение приходит с сервера? Тогда это не стор и не useState — это кеш запросов.
  • Нужно, чтобы ссылка открыла тот же экран? Тогда это адрес.
  • Нужно, чтобы пережило перезагрузку, но никому не пересылается? Тогда стор с сохранением в браузере.
  • Ни то ни другое? useState, и не усложняй.

Отдельно про стор: он оправдан, когда состояние одновременно клиентское, нужно несвязанным компонентам в разных частях дерева и меняется часто. Не выполняется хотя бы одно условие — стор преждевременен; хватит useState, контекста или адреса.

Коротко

  • Состояние экрана в useState теряется при обновлении страницы, не пересылается ссылкой и ломает кнопку «назад».
  • Фильтры, поиск, сортировка и номер страницы живут в адресной строке: адрес полностью описывает, что видит человек.
  • Параметры адреса разбирают схемой: приведение типов, значения по умолчанию и отсечение мусора в одном месте.
  • При смене фильтра номер страницы сбрасывают — иначе пользователь попадает в пустой список.
  • Частые изменения (ввод в поле поиска) пишут заменой записи истории и с задержкой, иначе «назад» перестаёт работать.
  • В адрес не кладут токены, персональные данные и длинные черновики: он попадает в журналы, историю и рефереры.
  • Данные с сервера — в кеш запросов; состояние интерфейса, переживающее перезагрузку, — в стор; остальное — useState.

Что почитать дальше

  • Состояние: локальное и серверное — подробнее про виды состояния и когда нужен стор.
  • Роутинг в React — маршруты, параметры пути и программная навигация.
  • Загрузка данных в React — как параметры из адреса становятся ключом кеша запроса.