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