Как сохранить фильтры в URL — чтобы ссылка открывала тот же вид, а «Назад» работал

Ты собрал каталог с фильтрами: цвет, размер, сортировка по цене. Всё хранится в состоянии компонента и работает отлично.
Ровно до трёх ситуаций. Отправил другу ссылку «смотри, какие красные» — у него открылся весь каталог. Нажал «Обновить» — фильтры сбросились. Кликнул на товар, вернулся «Назад» — опять всё с нуля.
Причина одна: состояние живёт в памяти вкладки, а ссылка, обновление и «Назад» работают с адресом. Чинится это переносом фильтров в адрес. Ещё в 1999 году Якоб Нильсен писал, что URL — часть интерфейса: его копируют, пересылают и ждут, что он будет открывать то же самое.
Шаг 1. Реши, что кладёшь в адрес, а что нет
Правило простое: в адрес идёт то, что человек захочет переслать или открыть заново.
- Да: фильтры, сортировка, номер страницы, поисковый запрос, открытая вкладка.
- Нет: раскрыт ли выпадающий список, наведение мыши, недопечатанный текст, всплывающие окна.
- Никогда: токены, пароли, личные данные. Адрес видят все — он оседает в истории браузера, в логах сервера и в пересланных сообщениях.
Для примера возьмём два фильтра — color и sort. Целевой адрес выглядит так: /shop?color=red&sort=price.
Шаг 2. Читай состояние из адреса при загрузке
Адрес становится источником правды. Компонент не придумывает начальные значения сам, а берёт их из строки.
const params = new URLSearchParams(location.search);
const color = params.get("color") ?? "all";
const sort = params.get("sort") ?? "popular";
URLSearchParams — встроенный в браузер разборщик строки запроса. get возвращает null, если параметра нет, — тогда подставляем значение по умолчанию.
Не доверяй адресу слепо: его может отредактировать кто угодно. Если пришёл sort=hack, которого у тебя нет, откатывайся к умолчанию, а не падай.
Шаг 3. Записывай в адрес при каждом изменении
Когда человек меняет фильтр, обновляй не только экран, но и строку.
function setFilter(key, value, defaultValue) {
const params = new URLSearchParams(location.search);
if (value === defaultValue) params.delete(key);
else params.set(key, value);
const query = params.toString();
history.replaceState(null, "", query ? "?" + query : location.pathname);
}
Две детали внутри:
- Значения по умолчанию не пиши. Если выбрано «все цвета», параметр удаляется. Адреса выходят короче, а одна и та же выдача не расползается на
/shopи/shop?color=all. - Не склеивай строку руками.
URLSearchParamsсам закодирует кириллицу и спецсимволы, а пробел, по MDN, превратит в+. Самодельное"?q=" + textсломается на первом амперсанде в запросе.
Шаг 4. Выбери: replaceState или pushState
Это самое важное решение в инструкции. От него зависит, как работает кнопка «Назад».
history.replaceStateзаменяет текущую запись истории. «Назад» уводит туда, откуда человек пришёл на страницу.history.pushStateдобавляет новую запись. «Назад» возвращает к прошлому набору фильтров.
Какой брать:
- Поиск по мере ввода → replace. Иначе «Назад» будет стирать запрос по одной букве.
- Сортировка, мелкие переключатели → replace. Мало кто считает их отдельным «местом».
- Категория, вкладка, номер страницы → push. Человек воспринимает это как переход и ждёт, что «Назад» вернёт предыдущий экран.
Если используешь pushState, добавь обработчик возврата. По MDN, событие popstate приходит, когда человек жмёт «Назад» или «Вперёд», а сам вызов pushState его не вызывает.
window.addEventListener("popstate", () => {
renderFromUrl(); // та же функция, что в шаге 2
});
Шаг 5. Если у тебя React или Next.js
Логика та же, меняются только инструменты.
- Next.js (App Router). Читать — хуком
useSearchParams: он возвращает URLSearchParams только для чтения. Писать — черезuseRouter:router.pushдобавляет запись в историю,router.replace— нет. Чтобы страница не прыгала наверх при каждом клике по фильтру, передай{ scroll: false }. - React Router. Есть хук
useSearchParams, который возвращает параметры и функциюsetSearchParamsдля их записи.
Как вообще адрес меняется без перезагрузки страницы, мы разбирали в статье что такое клиентский роутинг.
Шаг 6. Передай фильтры на сервер
Фильтры в адресе — это ещё и готовый запрос к API. Можно отправлять ту же строку как есть:
const res = await fetch("/api/products?" + params.toString());
Сервер разбирает её точно так же. Как связать клиент с сервером целиком, показали в гайде как связать фронтенд и бэкенд, а про поиск по базе — в разборе как добавить поиск в приложение.
Что получится
- Ссылка
/shop?color=red&sort=priceу друга открывает ровно тот же экран. - «Обновить» ничего не сбрасывает.
- «Назад» после перехода в товар возвращает к отфильтрованному списку.
- Выбор категорий через
pushStateлистается кнопками «Назад» и «Вперёд», а набор поиска их не засоряет.
Проверь все четыре пункта руками: скопируй адрес в другое окно, обнови, походи назад-вперёд.
Короткие уроки-истории, симулятор агента и ежедневная практика — в нашем мобильном приложении. Бесплатно.
Что будет с кириллицей в адресе?
URLSearchParams закодирует её сам: в строке появятся знаки процента, а get вернёт исходный текст. В адресной строке браузер обычно всё равно показывает буквы читаемыми.
Как хранить фильтр, где можно выбрать несколько значений?
Повтори параметр: ?size=42&size=43. Добавляй значения через params.append("size", "43"), а читай все сразу через params.getAll("size") — получишь массив. Не путай с set: по MDN, он удаляет все остальные значения этого ключа.
Можно хранить фильтры в localStorage вместо адреса?
Можно, если нужно «запомнить мой выбор на этом устройстве». Но ссылкой такой вид не поделишься, и в новой вкладке у другого человека он не откроется. Часто делают оба: адрес — главный, localStorage — запасной.
Не повредят ли десятки вариантов адреса SEO?
Могут размыть: поисковик увидит много похожих страниц. Обычно на отфильтрованных вариантах указывают канонический адрес основного каталога, а в индекс пускают только важные сочетания.





