React Router DOM - навигация по страницам в React
Пока в React-приложении две страницы, навигация кажется задачей на пять минут. Добавили BrowserRouter, пару Route, поставили Link - можно продолжать разработку.
Проблемы обычно начинаются на третьей неделе проекта, а не на третьей странице.
Появляется карточка товара, которую нужно открыть по /products/42. Затем личный кабинет с собственным меню. Потом форма, после сохранения которой надо вернуть пользователя на предыдущий экран. В какой-то момент разработчик обнаруживает, что URL уже приходится учитывать почти в каждом месте приложения.
И вот тогда становится понятно, зачем вообще нужен React Router.
Когда useState уже не хватает
Самый простой способ переключать экраны выглядит примерно так:
//jsx
const [page, setPage] = useState("home");Дальше:
//jsx
{page === "home" && <Home />}
{page === "products" && <Products />}Для небольшого прототипа решение нормальное.
Но пользователь при этом находится на одном и том же URL. Если открыть каталог, адрес в браузере не изменится. Страницу нельзя нормально добавить в закладки, отправить ссылкой или открыть напрямую после обновления.
Можно начать самостоятельно синхронизировать состояние с window.location и history. Только это уже постепенно превращается в собственную систему маршрутизации.
React Router решает ту же задачу готовыми средствами: связывает URL с интерфейсом, работает с историей браузера и позволяет описать структуру маршрутов приложения.
Базовый вариант
В актуальной документации React Router простой сценарий называется Declarative Mode. Для него используется BrowserRouter, а маршруты описываются через Routes и Route.
//jsx
import {
BrowserRouter,
Routes,
Route,
Link
} from "react-router";
function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Главная</Link>
<Link to="/products">Товары</Link>
<Link to="/about">О нас</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/about" element={<About />} />
</Routes>
</BrowserRouter>
);
}Здесь нет ничего особенно сложного. Route говорит, какой интерфейс соответствует определённому URL, а Link используется для перехода между маршрутами.
Именно с Link часто начинается первая практическая ошибка.
Почему внутренние ссылки лучше делать через Link
Такой код выглядит совершенно нормально:
//html
<a href="/products">Товары</a>Но это обычная браузерная ссылка. Браузер воспринимает её как переход на новый документ.
Для внутреннего маршрута React-приложения обычно используют:
//jsx
<Link to="/products">Товары</Link>Теперь переход обрабатывает React Router.
Разница становится особенно заметной, когда приложение уже хранит состояние, загруженные данные и сложный интерфейс. Полная перезагрузка документа при обычном переходе в таком приложении просто не нужна.
При этом <a> никуда не исчезает. Для внешнего сайта:
//jsx
<a href="https://example.ru">Сайт</a>это по-прежнему обычный и правильный вариант.
Проблема только в том, когда <a> используют там, где нужен внутренний переход.
Товар с id - уже другой разговорКаталог почти никогда не заканчивается адресом /products.
Нужны:
//text
/products/15
/products/27
/products/103Создавать три отдельных маршрута никто не станет.
В React Router для этого есть динамический сегмент:
//jsx
<Route
path="/products/:id"
element={<Product />}
/>Теперь :id берётся непосредственно из URL.
//jsx
import { useParams } from "react-router";
function Product() {
const { id } = useParams();
return <h1>Товар №{id}</h1>;
}Для /products/103 значение id будет "103". Причём это именно строка - если API ожидает число, преобразование нужно сделать отдельно. useParams возвращает значения динамических параметров текущего совпавшего маршрута.
Например:
//jsx
const productId = Number(id);Дальше уже можно обращаться к API:
//jsx
fetch(`/api/products/${productId}`);На практике здесь появляется важный архитектурный вопрос: кто должен загружать товар - сам компонент или маршрут?
Для небольшого проекта вполне нормально оставить это компоненту:
//jsx
function Product() {
const { id } = useParams();
const [product, setProduct] = useState(null);
useEffect(() => {
fetch(`/api/products/${id}`)
.then(response => response.json())
.then(setProduct);
}, [id]);
// ...
}Если таких страниц становится много, ситуация начинает меняться.
Когда маршрут начинает отвечать ещё и за данные
React Router умеет связывать маршрут с loader.
Например:
//jsx
const router = createBrowserRouter([
{
path: "/products/:id",
loader: async ({ params }) => {
const response = await fetch(
`/api/products/${params.id}`
);
if (!response.ok) {
throw new Response("Product not found", {
status: 404
});
}
return response.json();
},
Component: Product
}
]);В data mode загрузчики выполняются при переходе до отображения соответствующего route component, а результат можно получить через useLoaderData.
//jsx
import { useLoaderData } from "react-router";
function Product() {
const product = useLoaderData();
return <h1>{product.name}</h1>;
}Здесь мне нравится именно то, что запрос больше не спрятан где-то внутри useEffect. По маршруту сразу видно: для /products/:id нужны такие-то данные.
Это становится полезно, когда приложение уже состоит не из пяти компонентов.
Вложенные маршруты
Личный кабинет хорошо показывает другую проблему.
Допустим, есть:
//text
/account/profile
/account/orders
/account/securityУ всех страниц общий каркас:
//text
┌──────────────────────────────┐
│ Личный кабинет │
├────────────┬─────────────────┤
│ Профиль │ │
│ Заказы │ содержимое │
│ Настройки │ │
└────────────┴─────────────────┘Можно сделать три компонента и повторить боковое меню в каждом. Можно сделать AccountLayout и дать дочерним маршрутам место внутри него.
//jsx
<Route path="/account" element={<AccountLayout />}>
<Route path="profile" element={<Profile />} />
<Route path="orders" element={<Orders />} />
<Route path="security" element={<Security />} />
</Route>В самом layout:
//jsx
import { Outlet } from "react-router";
function AccountLayout() {
return (
<>
<aside>
<Link to="/account/profile">Профиль</Link>
<Link to="/account/orders">Заказы</Link>
<Link to="/account/security">Настройки</Link>
</aside>
<main>
<Outlet />
</main>
</>
);
}
Outlet - это место, куда React Router вставит дочерний маршрут.
Такая схема особенно хорошо читается в больших разделах приложения. По самому дереву маршрутов уже понятно, какие страницы относятся к аккаунту.
useNavigate лучше не превращать в универсальную кнопку
Есть другой сценарий. Пользователь заполнил форму, сервер сохранил данные, после этого нужно отправить его на страницу профиля.
Здесь Link уже не подходит:
//jsx
const navigate = useNavigate();
async function handleSubmit() {
await saveProfile();
navigate("/account/profile");
}То же самое с возвратом:
//jsx
navigate(-1);useNavigate полезен именно для программной навигации.
Если на странице есть обычный пункт меню, который ведёт на /account/orders, проще написать:
//jsx
<Link to="/account/orders">Заказы</Link>чем прятать тот же переход в onClick.
Это небольшая вещь, но она сильно влияет на читаемость маршрутизации.
А что выбрать: BrowserRouter или createBrowserRouter
В документации React Router сейчас выделяются три режима: Declarative, Data и Framework. Declarative Mode закрывает обычную маршрутизацию, Data Mode добавляет загрузку данных, actions и состояния переходов, а Framework Mode строится поверх Data Mode и добавляет более широкий набор возможностей.
Поэтому BrowserRouter и createBrowserRouter лучше не воспринимать как две конкурирующие записи одного и того же.
Если нужен обычный роутинг:
//jsx
<BrowserRouter>
<App />
</BrowserRouter>Если хочется использовать data APIs:
//jsx
const router = createBrowserRouter(routes);
createRoot(root).render(
<RouterProvider router={router} />
);Data Router при этом создают один раз вне React-дерева, а не внутри компонента. Это прямо указано в документации.
Например, так:
//jsx
const router = createBrowserRouter(routes);
function App() {
return <RouterProvider router={router} />;
}а не так:
//jsx
function App() {
const router = createBrowserRouter(routes);
return <RouterProvider router={router} />;
}На простом проекте это выглядит как мелочь. Когда маршрутизатор начинает хранить больше состояния, лучше сразу придерживаться правильной схемы.
Есть ещё один момент со старыми примерами
Если искать уроки по React Router, легко наткнуться на:
//jsx
import { Link, Route } from "react-router-dom";В актуальной документации React Router установка для новых проектов уже показывает пакет react-router, а BrowserRouter, Routes, Route, Link и другие API импортируются оттуда. Для Data Mode RouterProvider импортируется из react-router/dom.
Поэтому старый код с react-router-dom не стоит механически переносить в новый проект. Сначала лучше посмотреть версию библиотеки и соответствующую документацию.
Это особенно актуально для статей и видео, которым несколько лет: API может выглядеть почти знакомо, но рекомендуемый способ сборки приложения уже изменился.
Ошибка, которая появляется после публикации
Есть проблема, которую обычно замечают уже после первого деплоя.
Внутри приложения переход:
//text
/products/42работает нормально.
Потом пользователь нажимает F5.
И получает 404.
Причина часто вообще не в React Router.
Когда пользователь переходит по ссылке внутри SPA, JavaScript уже загружен и роутер сам разбирает URL.
При прямом открытии /products/42 первым получает запрос сервер:
//text
GET /products/42Если сервер не настроен отдавать приложение для клиентских маршрутов, он пытается найти такой ресурс и возвращает 404.
Для SPA сервер должен корректно передавать управление приложению на такие URL. Конкретная настройка зависит от сервера и способа деплоя.
Поэтому проверять маршрутизацию нужно не только кликами внутри приложения.
Стоит отдельно открыть в браузере:
//text
/products/42и обновить страницу.
Именно этот тест довольно быстро показывает, готово ли приложение к нормальной работе после публикации.
Страница 404 тоже должна быть маршрутом
Внутри клиентского приложения неизвестные адреса можно обработать:
//jsx
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products/:id" element={<Product />} />
<Route path="*" element={<NotFound />} />
</Routes>Теперь React Router покажет NotFound, если URL не совпал с описанными маршрутами.
Но здесь важно помнить о предыдущей проблеме: этот компонент сможет сработать только после загрузки самого React-приложения. Если сервер отдаёт настоящий 404 раньше, до NotFound дело не дойдёт.
Как я бы строил маршруты на реальном проекте
Я бы не начинал с компонентов.
Сначала выписал бы адреса:
//text
/
/products
/products/:id
/account
/account/orders
/account/orders/:idПосле этого уже становится видно, где нужны параметры, где будет вложенность и какие части интерфейса можно сделать общими.
Например:
//text
/account
├── profile
├── orders
│ └── :id
└── settingsизначально говорит гораздо больше, чем набор разрозненных Route.
И здесь React Router полезен не количеством API. Его ценность появляется, когда URL начинают нормально отражать устройство приложения.
Link решает обычную навигацию. useNavigate - программные переходы. useParams - данные из URL. Outlet - вложенный интерфейс. Data Router добавляет загрузку и изменение данных, если проект до этого дорос.
Не обязательно использовать всё сразу.
Для приложения из нескольких экранов BrowserRouter и несколько Route могут быть всем, что нужно.
А если через полгода в проекте появились каталог, личный кабинет, вложенные разделы и десятки URL, уже хорошо, когда эта структура была заложена в маршрутах с самого начала.
Потому что в этот момент роутинг перестаёт быть отдельной библиотекой, которую нужно «настроить». Он становится частью архитектуры приложения.
Комментарии
Чтобы оставить комментарий, войдите в аккаунт.