Рад приветствовать вас, друзья. Я Сергей, фронтенд-разработчик в Clevertec и хочу поделиться с вами опытом использования последней обновленной 7-й версии React Router в одном из крупных проектов нашей компании.
В процессе разработки мы часто сталкиваемся с проблемами навигации в больших React-приложениях: сложной логикой авторизации, множеством вложенных страниц и подстраниц, необходимостью проверок или отправкой данных при переходе на определенную страницу. React Router v7 предлагает элегантные решения этих проблем при правильном использовании.
В этой статье я расскажу, как мы реорганизовали структуру роутинга, используя возможности этой библиотеки:
избавились от лишних «букав кода»
улучшили производительность приложения
создали легко масштабируемую, более понятную и читаемую для разработчиков навигацию по страницам
создали полезные хелперы, которые вместо «кастомных хуков» теперь используются внутри самой системы постраничной навигации в рамках SPA-приложения.
Показываю на примере
Для статьи я подготовил небольшой пример, который в схожем виде нашел отражение в одном из проектов нашей компании. Дизайн минималистичный и создан для демки) Исходный код в свободном доступе лежит на моем гитхаб-аккаунте.
Для начала сделаем минимальную преднастройку с использованием prettier, eslint, typescript, react, react-router (в 7-й версии нам больше не нужен "react-router-dom", пакеты были упрощены до библиотеки "react-router"). Также следует учесть, что необходимо наличие на проекте NodeJS не ниже 20-й, пакетов react и react-dom версий не ниже 18-й.
В исходном приложении имеются несколько стартовых страниц, которые представляют собой степ-страницы.

Страница приветствия содержит кнопки перехода к степу входа в приложение (вход требует дополнительных проверок, причем совершенно любых), и входа через сторонний сервис (предположим, google. Для статьи не имеет значения, поэтому сделаем эту кнопку «disabled»).
При нажатии на вход через предустановленное расширение (предположим, у нас есть некий browser extension), мы переходим на страницу входа в приложение через расширение. При нажатии на кнопку перехода к таблицам переходим на основную страницу приложения с функционалом, который требует предустановленного и включенного расширения. Также есть возможность возврата на страницу приветствия (первый степ).

В момент перехода или при ручном переходе по роуту основной страницы у нас должна произойти проверка наличия и включенного состояния расширения.

И финальный степ приложения, который имитирует основную страницу.

В случае, если результат проверки неуспешен, происходит редирект на страницу, предлагающую установить или включить расширение. В данном случае переход на финальную страницу будет невозможен.

http://localhost:3000/steps/extension-not-installed
Отмечу особенности наших страниц:
схожий дизайн всех степов
есть явные общие блоки
для всех страниц, кроме приветствия и входа через расширение необходима проверка наличия и включенного состояния расширения (доступность конкретной страницы, переход от странице к странице зависит от результатов проверки).
Как же реализовать такую задачу?
Сделать отдельно страницу для каждого степа, использовать стейт менеджер или динамические параметры в роутах, плюс знакомые всем «кастомные хуки»… Или есть вариант попроще.

Используем возможности реакт-роутера
1) создадим роутер и подключим его в приложение, используя RouterProvider (в новой 7-й версии импортируется несколько иначе, чем в предыдущих)
import { RouterProvider } from 'react-router/dom';
import classes from './app.module.css';
import { AppRouter } from './app-router';
export const App = () => {
return (
<main className={classes.content}>
<RouterProvider router={AppRouter} />
</main>
);
};
И непосредственно сам роутер
import { createBrowserRouter } from 'react-router';
import { notFound } from './routes/not-found';
import { startPage } from './routes/start-page';
import { anyRoute } from './routes/any-route';
export const AppRouter = createBrowserRouter([startPage, notFound, anyRoute]);
При использовании подхода через createBrowserRouter или createHashRouter каждый роут представляется в отдельном виде. Заодно можем использовать подход организации роутера в отдельной папке и каждый роут поместить в отдельную подпапку с уровнем вложенности, соответствующей нашей структуре страниц:

Очень напоминает app-router из nextjs, не так ли? Теперь станет проще ориентироваться в структуре страниц, совсем как в древовидной структуре папок файлового менеджера.
Каждый роут представляет собой следующие структуры.
Страница 404:
import { type RouteObject } from 'react-router';
import { ROUTES } from '~constants/routes';
import { NotFoundPage } from '~pages/not-found-page';
export const notFound: RouteObject = {
path: ROUTES.notFound,
element: <NotFoundPage />,
};
Редирект на 404-ю страницу при несуществующем роуте:
import { Navigate, RouteObject } from 'react-router-dom';
import { ROUTES } from '~constants/routes';
export const anyRoute: RouteObject = {
path: ROUTES.anyRoute,
element: <Navigate to={ROUTES.notFound} replace={true} />,
};
Основная страница приложения:
import { Navigate, type RouteObject } from 'react-router';
import { BaseLayout } from '~components/base-layout';
import { ROUTES } from '~constants/routes';
import { PageSteps } from '~types/page-steps';
import { stepsRoot } from './steps-root';
export const startPage: RouteObject = {
path: ROUTES.mainPage,
element: <BaseLayout />,
children: [
{
index: true,
element: <Navigate to={`${ROUTES.stepsRootPage}/${PageSteps.Greeting}`} />,
},
stepsRoot,
],
};
На примере основной страницы приложения видно, как через children можно создавать вложенные роуты, которые являются не чем иным, как вложенными веб-страницами (тут же вспоминаем про динамические роуты и хук useParams).
Теперь коснемся глубже роута степ-страниц:
import { Navigate, type RouteObject } from 'react-router';
import { ROUTES } from '~constants/routes';
import { LazyStartPage } from '~pages/lazy-start-page';
import { MainPagesLayout } from '~components/main-pages-layout';
import { loaderCheckExtension } from '../../../utils/loader-check-extension';
import { PageSteps } from '~types/page-steps';
export const stepsRoot: RouteObject = {
path: ROUTES.stepsRootPage,
element: <MainPagesLayout />,
children: [
{
index: true,
element: <Navigate to={`${ROUTES.stepsRootPage}/${PageSteps.Greeting}`} />,
},
{
path: ROUTES.stepPage,
element: <LazyStartPage />,
loader: loaderCheckExtension,
},
],
};
Вот здесь начинается самое интересное – подход создания роутера через createBrowserRouter позволяет использовать loader – это любая функция (проверка, ютилка, хелпер), которая будет выполнятся при переходе на выбранный роут, не производя рендер самой страницы без результатов проверки.
Заглянем глубже в реализацию имитации типовой проверки, например, наличия расширения в браузере.
import { type Params } from 'react-router';
import { STEP_PARAMS_NAME } from '~constants/routes';
import { PageSteps } from '~types/page-steps';
const LOADER_VISIBLE_DURATION_MS = 3000;
// flag для проверки успешности проверки - можно поэкспериментировать и поменять
const PROMISE_RESOLVED: boolean = false;
// для кеширования результата проверки
const cache: { isAlreadyChecked?: boolean } = {};
// здесь может быть любая асинхронная проверка
const customCheck = () =>
new Promise<boolean>((resolve) => {
if (cache.isAlreadyChecked !== undefined) {
resolve(cache.isAlreadyChecked);
} else {
setTimeout(() => {
cache.isAlreadyChecked = PROMISE_RESOLVED;
resolve(PROMISE_RESOLVED);
}, LOADER_VISIBLE_DURATION_MS);
}
});
/*
Функция-лоадер может принимать аргументы следующего типа:
type LoaderFunctionArgs<Context> = {
context?: Context;
params: Params<string>;
request: Request;
}
*/
export const loaderCheckExtension = ({ params }:
{ params: Params<typeof STEP_PARAMS_NAME> }) => {
const currentStep = params.paramName;
// пропускаем проверку для определенных страниц
- у нас это первая и вторая страницы - не требуют проверки наличия расширения
if (currentStep === PageSteps.Start || currentStep === PageSteps.Greeting) {
return { hasExtension: true };
}
/* возвращаем промис - причем без await - чтобы в дальнейшем использовать
Suspense и fallback, если здесь использовать await - то перехода по роуту
не случится до завершения await (в версии реакт-роутер-дом до 7 - нужно
было использовать
defer({returnedObject})) для получения аналогичного эффекта
*/
const hasExtension = customCheck();
return { hasExtension };
};
Вот в этих строках кода и скрыта некоторая магия. Мы не проверяем роуты степ-страниц для приветствия и стартовой страницы (путем прямого resolve Promise), а на всех остальных страницах мы делаем данную проверку. И не важно, каким образом мы оказались на данном роуте: по клику на кнопку, или редиректом, или вводом напрямую в адресной строке.
Создадим искусственно Promise, имитирующий проверку. На вход loader может принимать params – те самые, которые мы потом сможем получить в useParams внутри реакт-компонента. Таким образом мы сможем точно понять, на какую страницу собираемся перейти.
Для того, чтобы получить улучшенный UX для страницы, пока работает асинхронная проверка, мы должны вернуть из лоадера Promise, причем без использования await, чтобы можно было использовать Suspense компонент из React.
Используем полученные результаты
Все тоже очень просто:
import { Suspense } from 'react';
import { Await, useLoaderData, useParams } from 'react-router';
import { CustomSpinner } from '~components/custom-spinner';
import { EntryLoaderData, StepParams } from '~types/router';
import { MainPage } from '~pages/main-page';
import { PageSteps } from '~types/page-steps';
const EXTENSION_CHECKED_TEXT = 'Идет процесс проверки ...';
export const LazyStartPage = () => {
const data = useLoaderData() as EntryLoaderData;
const { paramName } = useParams<StepParams>();
/*
те шаги, которые не требуют проверки, выносим за Await
- иначе мы можем не получить fallback при переходе с изначальных страниц на финальную
при данной кастомной реализации лоадера
*/
if (paramName === PageSteps.Start || paramName === PageSteps.Greeting) {
return <MainPage hasExtension={true} />;
}
return (
<Suspense fallback={<CustomSpinner tip={EXTENSION_CHECKED_TEXT} />}>
<Await
resolve={data.hasExtension}
errorElement={<div>Error during render page!</div>}>
{(props: boolean) => <MainPage hasExtension={props} />}
</Await>
</Suspense>
);
};
Здесь нам на помощь приходят Suspense, а также Await и useLoaderData из реакт-роутера.
Этот пример кода позволяет создать LazyPage (ленивую страницу). При каждой проверке расширения будет отображаться fallback-компонент в виде кастомного спиннера (в нашем случае) или любого другого переданного вместо спиннера компонента. Fallback-компонент будет отображаться до тех пор, пока не завершится проверка.
Компонент Await в качестве пропса передает своим children как раз тот параметр, который мы получили при отработке лоадера – через пропс resolve.
Ну а сами данные получаем из хука useLoaderData, также предоставляемого react-router. Минималистично и просто – не правда ли?)
Далее отрисовываем необходимые нам страницы, например MainPage. Заодно здесь реализуем логику защиты доступа к страницам, без положительных результатов проверки на наличие расширения (своеобразный ProtectedRoute).
import { FC } from 'react';
import { Navigate, useParams } from 'react-router';
import { ROUTES } from '~constants/routes';
import { PageSteps } from '~types/page-steps';
import { StepParams } from '~types/router';
import { EnterPage } from '~pages/enter-page';
type MainPageProps = {
hasExtension: boolean;
};
export const MainPage: FC<MainPageProps> = ({ hasExtension }) => {
const { paramName } = useParams<StepParams>();
if (!hasExtension && paramName === PageSteps.Final) {
return <Navigate
to={`${ROUTES.stepsRootPage}/${PageSteps.ExtensionNotInstalled}`} />;
}
return <EnterPage />;
};
Вся дальнейшая реализация не является целью данной статьи и представляет собой известные подходы реакт – стандартные реакт-компоненты, использование хука useParams для получения текущего степа. Более детально можно посмотреть в исходном коде примера.
Хочу отметить, что весь дополнительный функционал: useLoaderData, Await, сами лоадеры и многое другое доступны только при создании роутинга через сreateBrowserRouter или createHashRouter библиотеки «react-router».
Есть хорошие новости для разработчиков, использующих фреймворк Remix. Логика роутинга Remix как раз и реализована в библиотеке 7-й версии реакт роутера. Отмечу также, что 7-я версия привнесла возможность использования react-router уже как фреймворка, а не только библиотеки, что заслуживает написания отдельной статьи.
Чего на деле удалось достигнуть в реальном проекте при использовании библиотеки react-router v7:
1. Создана читаемая структура роутинга наподобие nextjs – древовидная, как у файлового менеджера.
2. Логика проверки расширения вынесена в компактный компонент, который в зависимости от динамического степа делает проверку до момента рендера страниц.
3. Сочетание Suspense из react; useLoaderData, Await и defer из react-router-dom позволило не использовать стейт-менеджеры и контексты для отображения различных лоадеров, добиться снижения количества перерендеров компоненты. Код стал более компактный, читаемый и масштабируемый.
4. Код компоненты входной страницы (аналог той самой EntryPage) сократился в 5 раз при соответствующем рефакторинге.
Буду рад, если опыт использования react-router с расширенной функциональностью поможет и вам. Вопросы в комментах приветствуются. Happy coding!