При клиентском роутинге приложение перехватывает клики по ссылкам, меняет URL через History API (history.pushState) и рендерит подходящие компоненты без полной перезагрузки страницы. При серверной навигации для каждого URL запрашивается новый HTML-документ.
Выигрыш: нет белой вспышки, лейаут и его состояние сохраняются между страницами, загружаются только изменившиеся данные и код.
Назад и вперёд: роутер слушает popstate и перерендеривает экран для нового URL.
Цена: первая загрузка тянет JS-приложение; для SEO и быстрой первой отрисовки нужен SSR или пререндер; прокруткой, фокусом и заголовком документа приложение управляет само.
Глубокие ссылки: сервер должен отвечать на любой URL приложения файлом index.html, иначе перезагрузка /users/42 вернёт 404.
ReactMiddle
Роутинг
React Router v7, вложенные маршруты и layout, params и search params, loaders и actions, защищённые маршруты, разбиение кода по маршрутам
Закрыто вопросов
0 / 21
- 0Повторить
- 8Часто спрашивают
Вопросы
React Router v7 работает в декларативном режиме (<BrowserRouter> с <Routes>), в data-режиме (createBrowserRouter и RouterProvider, которые добавляют лоадеры, экшены и состояния ожидания) или во фреймворк-режиме (бывший Remix: Vite-плагин, роуты из файлов, SSR и сгенерированные типы).
Всё поставляется в одном пакете react-router; react-router-dom остался только как реэкспорт для упрощения миграции.
Декларативный: стиль <Routes> из v6; роутер сопоставляет URL, а загрузка данных остаётся в компонентах.
Data: роуты описываются объектами вне рендера, поэтому роутер может загрузить данные до рендера и обрабатывать формы. Обычный выбор для нового SPA.
Фреймворк: фулстек-приложение с серверным рендерингом, разделением кода по роутам и типобезопасным loaderData.
<Link> рендерит <a>, который выполняет навигацию на клиенте, <NavLink> представляет собой <Link>, знающий, активен ли он (для меню и вкладок), а useNavigate переходит из кода, например после сабмита. Обычный <a href> перезагружает всю страницу и теряет состояние приложения.
<Link> сохраняет настоящий href, поэтому Ctrl+клик, «открыть в новой вкладке» и краулеры продолжают работать; не заменяйте ссылки на <button onClick={...}> с вызовом navigate.
<NavLink> передаёт isActive и isPending в className, style и children и ставит активной ссылке aria-current="page".
navigate('/login', { replace: true }) заменяет текущую запись в истории; navigate(-1) возвращает назад.
В data-режиме лучше вернуть redirect('/done') из экшена или лоадера, чем вызывать navigate в эффекте.
Дочерние роуты вкладываются в родительский, а родитель рендерит <Outlet /> там, где должен появиться совпавший потомок. Сегменты URL складываются, родительский лейаут остаётся смонтированным при переходах между потомками, а index-роут заполняет outlet на собственном пути родителя.
Роут без path называется layout-роутом: он добавляет UI (оболочку, проверку авторизации), не добавляя сегмент в URL.
У каждого совпавшего уровня может быть свой лоадер и свой error boundary, поэтому упавший потомок показывает ошибку внутри родительского лейаута.
useOutletContext() читает значение, которое родитель передаёт через <Outlet context={...} />.
Ссылки относительные: внутри /projects/:projectId <Link to="settings"> ведёт на /projects/:projectId/settings.
Отметьте сегмент двоеточием в пути (users/:userId) и читайте его через useParams() в компоненте или из params в лоадере. Параметры всегда приходят строками или undefined, поэтому перед использованием их нужно распарсить и проверить.
Необязательный сегмент: :lang?/about совпадает и с /about, и с /en/about.
Splat: files/* захватывает остаток пути, он доступен как params['*'].
При смене только параметра (с /users/1 на /users/2) компонент остаётся смонтированным и его локальное состояние сохраняется; добавьте key={userId}, если его нужно сбросить.
Во фреймворк-режиме сгенерированные типы дают params.userId тип string прямо из пути роута.
createBrowserRouter использует настоящие пути через History API, createHashRouter держит маршрут после #, и сервер его не видит, а createMemoryRouter хранит историю в памяти для тестов и виджетов. Обновлённая глубокая ссылка отдаёт 404, потому что сервер ищет файл по этому пути.
Исправление для browser-роутинга: фолбэк, который на любой неизвестный путь отдаёт index.html: try_files $uri /index.html в nginx, try_files {path} /index.html в Caddy, правило rewrite на Netlify или Vercel. На GitHub Pages rewrite-правил нет, поэтому index.html копируют в 404.html или пререндерят роуты.
Hash-роутинг работает на любом статическом хостинге без настройки, но URL выглядят хуже, а хэш никогда не доходит до сервера, что исключает SSR.
Memory-роутер не трогает адресную строку; им рендерят роут по заданному URL в юнит-тесте.
Если приложение развёрнуто по подпути, передайте basename, чтобы ссылки и сопоставление учитывали префикс.
useSearchParams() возвращает URLSearchParams для текущей строки запроса и сеттер, который выполняет навигацию на новую строку. Читайте значения через get на каждом рендере и пишите через сеттер: так URL остаётся единственным источником правды для фильтров, сортировки и пагинации.
Значения приходят строками или null: парсите числа и подставляйте значения по умолчанию для отсутствующего или неверного ввода.
Сеттер заменяет всю строку запроса; чтобы изменить один ключ, используйте функциональную форму и сохраняйте остальные.
Для частых обновлений, например при вводе текста, передавайте { replace: true }, чтобы каждое нажатие не добавляло запись в историю.
В data-режиме лоадеры читают те же значения из new URL(request.url).searchParams и перезапускаются при изменении запроса, поэтому список для новых фильтров загружается автоматически.
setParams({ sort }) заменяет всю строку запроса этим объектом, поэтому q и page пропадают. Сеттер работает как переход на новый ?..., а не как слияние с текущим.
Функциональная форма получает текущий URLSearchParams; если изменить в нём один ключ и вернуть его, остальные сохранятся.
Сохранять ли page, решает продукт: после новой сортировки обычно логичнее первая страница, поэтому удаляйте параметр намеренно, а не случайно.
То же касается navigate('?sort=price') и <Link to="?sort=price">: оба заменяют всю строку запроса.
Лоадер представляет собой функцию роута, которую роутер вызывает с params и request до рендера этого роута; компонент читает результат через useLoaderData(). Данные для всех совпавших роутов начинают грузиться параллельно в момент навигации, а не после монтирования каждого компонента.
Нет водопадов: с эффектами родитель загружает данные, рендерится, и только потом потомок начинает свой запрос; лоадеры вложенного URL стартуют все сразу.
Нет флагов загрузки в каждом компоненте: роутер ждёт данные (или стримит их через <Await>), а useNavigation() даёт одно глобальное состояние ожидания.
Гонки обработаны: новая навигация отменяет предыдущую и прерывает её request.signal.
Ревалидация: после каждого экшена (сабмита формы) лоадеры на странице запускаются снова, и UI показывает свежие данные без ручного перезапроса.
Лоадеры хорошо сочетаются с TanStack Query: лоадер вызывает queryClient.ensureQueryData, чтобы начать загрузку пораньше, а компонент читает данные из кэша.
Экшен представляет собой функцию роута, которая обрабатывает не-GET отправку: <Form method="post"> сериализует поля, роутер вызывает action роута с request, а когда он завершится, все лоадеры на странице ревалидируются, и UI показывает свежие данные без ручного перезапроса.
Читайте ввод через await request.formData(); возвращайте ошибки валидации (их читают через useActionData()) или redirect('/projects/7').
Пока экшен выполняется, useNavigation().state равен 'submitting', и этого достаточно, чтобы заблокировать кнопку.
**useFetcher** отправляет или загружает данные без навигации: для встроенных действий вроде кнопки лайка или чекбокса задачи, каждое со своим fetcher.state.
Форма следует семантике HTML (атрибуты name, method), поэтому ей не нужны useState на каждое поле и обработчик onSubmit.
Оба URL совпадают с одним и тем же маршрутом, поэтому React Router оставляет EditUser смонтированным и лишь передаёт ему новые данные лоадера. useState читает свой аргумент только при первом рендере, поэтому name и email сохраняют значения пользователя 1.
Лоадер отработал: user уже указывает на пользователя 2, устарело только скопированное из него состояние. Тот же маршрут рендерит тот же тип компонента на той же позиции, и React считает это обновлением, а не новым монтированием.
Исправление: рендерить форму дочерним компонентом с key={user.id}. Новый ключ создаёт новый экземпляр, его состояние начинается заново с нового пользователя, и несохранённая правка пользователя 1 не утечёт к пользователю 2.
У неконтролируемых полей тот же баг: defaultValue тоже применяется только при монтировании, поэтому <Form> с полями на defaultValue нужен такой же key.
Синхронизация в эффекте хуже: useEffect(() => setName(user.name), [user]) рисует один кадр со старыми значениями, а после любого action на странице ревалидированный лоадер возвращает новый объект user, и эффект стирает то, что пользователь печатает.
Оберните приватные роуты в layout-роут, который проверяет сессию и либо рендерит <Outlet />, либо перенаправляет на страницу входа, запомнив, куда шёл пользователь. В data-режиме проверяйте в лоадере или middleware и делайте throw redirect('/login'), тогда приватная страница не отрендерится.
Guard в компоненте: во время рендера возвращайте <Navigate to="/login" replace state={{ from: location }} />; никогда не вызывайте navigate в эффекте, иначе сначала покажется приватная страница.
Guard в лоадере: лоадеры одной навигации выполняются параллельно, поэтому редирект в родительском лоадере не мешает стартовать дочерним. Middleware (React Router 7.9+) выполняется до всех лоадеров ветки и служит единым местом для проверки.
Пока сессия загружается, рендерите спиннер, а не редирект, иначе залогиненного пользователя будет выкидывать на страницу входа при каждой перезагрузке.
Настоящая защита находится на сервере: проверки на клиенте только прячут UI, а API должен отклонять запросы без валидной сессии.
Проверка ролей устроена так же: вложенный layout-роут для /admin, который проверяет роль.
Guard всегда рендерит <Outlet /> и делает редирект только в эффекте, который выполняется уже после того, как приватная страница закоммичена и отрисована. Потомок к тому же монтируется и может начать собственные запросы за приватными данными.
Решайте во время рендера: возвращайте <Navigate replace /> вместо outlet, и приватное дерево не смонтируется.
navigate('/login') без replace добавляет запись в историю, поэтому «Назад» вернёт на защищённый URL, и редирект сработает снова.
Пока сессия ещё загружается, user тоже пуст; без отдельного состояния загрузки залогиненный пользователь будет попадать на страницу входа при каждой перезагрузке.
Нужны два механизма для двух видов ухода: useBlocker останавливает навигацию внутри приложения (ссылки, navigate, «Назад» и «Вперёд») и позволяет показать собственный диалог; обработчик beforeunload покрывает перезагрузку, закрытие вкладки и ввод другого адреса, где браузер показывает свой диалог подтверждения.
**useBlocker** работает только в data или framework mode. Пока его state равен 'blocked', рендерите диалог, который вызывает blocker.proceed() или blocker.reset().
Передавайте функцию, которая сравнивает currentLocation.pathname с nextLocation.pathname, чтобы смена search-параметра или вкладки внутри страницы не вызывала вопрос.
Отправка формы тоже считается навигацией: <Form>, который отправляется на другой URL, блокируется, пока в форме есть изменения, а redirect, который вернул action, не блокируется. Сбрасывайте флаг изменений, когда сохранение прошло.
**beforeunload**: диалог включает event.preventDefault(); текст задаёт браузер, а показывается диалог, только если пользователь уже взаимодействовал со страницей. Подписывайтесь, только пока есть несохранённые изменения.
**unstable_usePrompt** делает то же через window.confirm; префикс unstable_ у него навсегда, потому что нативный диалог ведёт себя по-разному в разных браузерах.
Загружайте код роута, только когда его посещают: в data-режиме используйте свойство роута lazy, которое импортирует компонент, лоадер и экшен при навигации; в декларативном режиме оборачивайте компоненты React.lazy в <Suspense>. Фреймворк-режим разделяет каждый роут автоматически.
**Почему в data-режиме lazy лучше React.lazy**: роутер загружает модуль роута и запускает его лоадер ещё до рендера; с React.lazy код приходит только при рендере компонента, а загрузка данных внутри него стартует ещё позже, получается водопад.
Начиная с v7.5 lazy может быть и объектом с отдельными импортами (loader, Component), и лоадер не ждёт чанк компонента.
Маленькие роуты, которые посещают всегда, оставляйте в основном бандле; выносите тяжёлые (дашборды, редакторы, админку).
Предзагружайте вероятные следующие роуты: <Link prefetch="intent"> во фреймворк-режиме или ручной import() при наведении.
Задайте роутам ErrorBoundary (или errorElement): всё, что бросили в лоадере, экшене или рендере роута, покажет его вместо роута внутри родительского лейаута. Для ожидаемых ошибок бросайте Response или data() со статусом, а для неизвестных URL добавьте catch-all роут *.
useRouteError() возвращает то, что было брошено; isRouteErrorResponse(error) отличает брошенный ответ (со status, statusText, data) от неожиданного исключения.
Ошибки всплывают до ближайшего boundary вверх по дереву, поэтому корневой boundary служит страховкой, а вложенные сохраняют остальной лейаут рабочим.
Лоадер, который ничего не нашёл, должен делать throw data('Not found', { status: 404 }), а не возвращать null и оставлять обработку компоненту.
Если boundary нет вообще, React Router показывает стандартный экран ошибки для разработчика.
useNavigation() отдаёт глобальное состояние (idle, loading, submitting) для прогресс-бара. Медленные некритичные данные возвращайте из лоадера промисом без await и рендерьте через <Suspense> и <Await>, тогда страница появится, как только придут критичные данные.
По умолчанию роутер держит старую страницу на экране, пока не завершатся все лоадеры: для быстрых загрузок это естественно, для медленных выглядит как зависание.
<NavLink> получает isPending для ссылки, на которую идёт переход.
Ждите критичные данные (основную сущность, заголовок) и стримьте второстепенные (комментарии, рекомендации); промис должен быть полем возвращаемого объекта, а не самим возвращаемым значением.
useNavigation().location показывает, какая страница загружается, и по нему можно нарисовать скелетон целевого экрана.
Избегайте спиннеров, которые мелькают на 100 мс: показывайте индикатор с задержкой, чтобы при быстрых переходах его не было вовсе.
При клиентском роутинге документ не меняется, поэтому окно сохраняет позицию прокрутки предыдущей страницы, а при «Назад» браузер восстанавливает старое смещение раньше, чем отрендерились данные. <ScrollRestoration /> берёт это на себя: новые страницы начинаются сверху, «Назад» и «Вперёд» возвращают к сохранённой позиции.
Как работает: переключает history.scrollRestoration в 'manual', сохраняет window.scrollY для каждой записи истории в sessionStorage, а после каждой навигации, когда лоадеры завершились, восстанавливает сохранённое смещение или прокручивает к элементу из #hash либо наверх.
Рендерите его один раз в корневом маршруте. Ему нужен data или framework mode; с <BrowserRouter> прокручивайте наверх сами: window.scrollTo(0, 0) в эффекте на pathname.
**getKey** по умолчанию возвращает location.key, одну позицию на запись истории; возвращайте location.pathname, чтобы позиция списка восстанавливалась, даже если пользователь вернулся по ссылке.
**preventScrollReset** у <Link>, <Form> или navigate() сохраняет позицию при изменениях внутри страницы, например при смене вкладок или фильтров.
Обрабатывается только окно: если вёрстка прокручивается внутри <main> с overflow: auto, сохранять и восстанавливать scrollTop этого элемента придётся самим.
Данные, загруженные в эффекте, приходят после восстановления, пока страница ещё короткая; загружайте их в лоадере или отдавайте из кэша.
Для приложений, включивших все future-флаги v6, переход на v7 не ломает код: пакеты объединены в react-router, Remix стал его фреймворк-режимом, а лоадеры возвращают обычные объекты вместо json(). Мигрируют так: включают в v6 все флаги v7_, исправляют предупреждения, затем меняют импорты.
Один пакет: всё импортируется из react-router; react-router-dom остался только как реэкспорт.
Future-флаги стали поведением по умолчанию: v7_startTransition (обновления состояния роутера оборачиваются в startTransition), v7_relativeSplatPath, v7_fetcherPersist, v7_normalizeFormMethod, v7_partialHydration, v7_skipActionErrorRevalidation.
Утилиты данных: json() и defer() объявлены устаревшими: возвращайте объекты, для стриминга кладите в них промисы, а data() используйте, когда нужны статус или заголовки.
Фреймворк-режим: приложения на Remix v2 переходят на React Router v7 с помощью codemod и получают Vite-плагин, routes.ts и сгенерированные типы.
v7 требует React 18 или новее и Node 20 или новее.
React Router оставляет только последнюю навигацию: новый клик прерывает лоадеры предыдущей через request.signal, и устаревшие ответы не попадают в UI. После экшена все активные лоадеры по умолчанию ревалидируются, а shouldRevalidate позволяет роуту отказаться, если данные не могли измениться.
Отмена
Если передать request.signal в fetch, отмена станет настоящим прерванным HTTP-запросом; без этого запрос завершится, а его результат будет выброшен.
Две быстрые отправки одной формы: первая навигация прерывается, но сервер мог её уже обработать, поэтому мутации должны быть идемпотентными или защищёнными на сервере.
Фетчеры работают независимо, одновременно их может выполняться несколько, и роутер следит, чтобы более старый ответ не перезаписал данные из более нового.
Ревалидация
Триггеры: завершившийся экшен, изменение search params на том же роуте или вызов useRevalidator().revalidate().
shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }) возвращает false, чтобы пропустить ревалидацию, например для лоадера сайдбара, которому не важна строка запроса. Слишком агрессивный пропуск даёт устаревший UI, а такой баг найти сложнее, чем лишний запрос.
Начиная с v7, если экшен вернул или бросил ответ 4xx или 5xx, ревалидация по умолчанию пропускается.
С кэшем запросов
Когда лоадеры читают данные через TanStack Query (ensureQueryData), ревалидация попадает в кэш, а свежестью управляют staleTime и invalidateQueries.
Фреймворк-режим объединяет React Router и бывший Remix: Vite-плагин с routes.ts, серверные loader и action, SSR, пререндер или сборку SPA, разделение кода и сгенерированные типы роутов. Его выбирают для фулстек- или SEO-критичного приложения на веб-стандартных Request и Response.
Что он добавляет к data-режиму
Серверный код: loader и action выполняются на сервере, поэтому секреты и доступ к базе остаются там, а HTML приходит сразу с данными; clientLoader закрывает данные, доступные только в браузере.
Модули роутов: один файл экспортирует loader, action, meta, links, ErrorBoundary и компонент по умолчанию.
Сгенерированные типы: Route.LoaderArgs и Route.ComponentProps типизируют params и loaderData по конфигурации роутов.
Выбор рендеринга: ssr: true, prerender для статических страниц или ssr: false для обычного SPA.
По сравнению с Next.js App Router
Next.js построен вокруг React Server Components, стриминга и собственных слоёв кэширования; деплоить его можно куда угодно, но часть возможностей лучше всего работает на Vercel.
Фреймворк-режим React Router сохраняет более простую модель лоадеров и клиентских компонентов и деплоится на Node, Cloudflare и другие рантаймы через адаптеры; поддержка Server Components у него новее и менее зрелая.
Когда хватает data-режима
Внутренний инструмент или дашборд за логином с отдельным API: SEO не нужно, SSR не требуется, а статическое SPA дешевле хостить.