Урок 33 из 49 · Месяц 4. React
React Router: маршруты, useParams, useNavigate и lazy
Содержание урока
- Что такое SPA и маршрутизация
- Установка
- Способ 1. BrowserRouter, Routes и Route
- Навигация: Link и NavLink
- Вложенные маршруты, Layout и Outlet
- useParams: параметры из адреса
- useNavigate: переход из кода
- useSearchParams: строка запроса
- lazy(): ленивая загрузка страниц
- Способ 2. createBrowserRouter и RouterProvider
- errorElement
- Ленивые маршруты в data router
- Какой способ выбрать
- Типичные ошибки
- Практика
- Итоги
Пока всё наше приложение жило на одной странице. Но настоящему сайту нужны разделы: главная, каталог, карточка товара, профиль — и у каждого свой адрес в браузере. В этом уроке подключим React Router — стандартную библиотеку маршрутизации для React, научимся описывать страницы двумя способами (BrowserRouter + Routes и createBrowserRouter), читать параметры из адреса, переходить между страницами из кода, работать со строкой запроса и загружать страницы «лениво» через lazy().
Что такое SPA и маршрутизация
Обычный сайт при переходе по ссылке запрашивает у сервера новую HTML-страницу: экран мигает, всё грузится заново.
React-приложение — это SPA (Single Page Application, одностраничное приложение). HTML-страница одна (index.html), а «страницы» — это разные компоненты. При переходе браузер ничего не перезагружает: JavaScript меняет адрес в строке браузера и показывает другой компонент. Это быстро и плавно.
Маршрутизация (роутинг) — это правило «какой адрес → какой компонент»:
/ → <HomePage />
/products → <ProductsPage />
/products/42 → <ProductPage /> (товар с id = 42)
/about → <AboutPage />
всё остальное → <NotFoundPage /> (404)
Аналогия: React Router — это навигатор в торговом центре. Вы называете ему номер павильона (адрес), и он ведёт к нужному магазину (компоненту), не выводя вас на улицу.
Установка
npm install react-router
Актуальная версия React Router (v7) поставляется одним пакетом react-router. В старых проектах и туториалах вы встретите пакет react-router-dom и импорты из него — это то же самое API, просто раньше оно было разделено на два пакета.
Для примеров создадим страницы:
// src/pages/HomePage.jsx
export default function HomePage() {
return <h1>Главная</h1>;
}
// src/pages/AboutPage.jsx
export default function AboutPage() {
return <h1>О нас</h1>;
}
// src/pages/NotFoundPage.jsx
import { Link } from 'react-router';
export default function NotFoundPage() {
return (
<div>
<h1>404 — страница не найдена</h1>
<Link to="/">Вернуться на главную</Link>
</div>
);
}
Способ 1. BrowserRouter, Routes и Route
Это классический, «декларативный» способ: маршруты описываются JSX-компонентами.
// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import App from './App';
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);
// src/App.jsx
import { Routes, Route } from 'react-router';
import HomePage from './pages/HomePage';
import AboutPage from './pages/AboutPage';
import NotFoundPage from './pages/NotFoundPage';
function App() {
return (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
);
}
export default App;
Разбор:
BrowserRouterоборачивает всё приложение. Он следит за адресной строкой браузера и даёт доступ к роутингу всем компонентам внутри.Routes— контейнер маршрутов: смотрит на текущий адрес и выбирает один подходящийRoute.Route path="/about" element={<AboutPage />}— «если адрес/about, покажиAboutPage». Обратите внимание: вelementпередаётся JSX-элемент<AboutPage />, а не имя компонента.path="*"— «звёздочка» подходит под любой адрес. Это страница 404.
Навигация: Link и NavLink
Обычная ссылка <a href="/about"> перезагрузит всю страницу — всё состояние приложения пропадёт. Внутри приложения используйте компонент Link:
import { Link } from 'react-router';
<Link to="/about">О нас</Link>
Link рисует обычный тег <a>, но перехватывает клик и меняет адрес без перезагрузки.
NavLink — та же ссылка, но она «знает», активна ли сейчас, и может подсветиться. Это нужно для меню:
import { NavLink } from 'react-router';
import styles from './Header.module.css';
function Header() {
const getClass = ({ isActive }) => (isActive ? `${styles.link} ${styles.active}` : styles.link);
return (
<nav className={styles.nav}>
<NavLink to="/" end className={getClass}>Главная</NavLink>
<NavLink to="/products" className={getClass}>Каталог</NavLink>
<NavLink to="/about" className={getClass}>О нас</NavLink>
</nav>
);
}
export default Header;
- В
classNameуNavLinkможно передать функцию: она получает{ isActive }и возвращает класс. endу ссылки на/означает «активна только при точном совпадении». Иначе главная подсвечивалась бы на любой странице, ведь все адреса начинаются с/.
Вложенные маршруты, Layout и Outlet
На большинстве сайтов шапка и подвал одинаковы на всех страницах, меняется только середина. Чтобы не вставлять <Header /> в каждую страницу, делают макет (layout) и вложенные маршруты.
// src/layouts/MainLayout.jsx
import { Outlet } from 'react-router';
import Header from '../components/Header';
export default function MainLayout() {
return (
<>
<Header />
<main style={{ padding: 24 }}>
<Outlet />
</main>
<footer>© 2026 Atlas Shop</footer>
</>
);
}
// src/App.jsx
<Routes>
<Route path="/" element={<MainLayout />}>
<Route index element={<HomePage />} />
<Route path="products" element={<ProductsPage />} />
<Route path="products/:id" element={<ProductPage />} />
<Route path="about" element={<AboutPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
Разбор:
Outlet— «окошко», куда подставляется дочерняя страница. Шапка и подвал остаются на месте, меняется только содержимоеOutlet.- Дочерние
pathпишутся без слеша в начале — они продолжают путь родителя:productsвнутри/даёт/products. index— маршрут по умолчанию: что показать вOutletпо адресу ровно/.
useParams: параметры из адреса
Сделать отдельный маршрут для каждого из 1000 товаров невозможно. Вместо этого в пути делают параметр — часть адреса с двоеточием: products/:id. Под него подходит /products/1, /products/42, /products/abc.
Достать значение параметра помогает хук useParams:
// src/pages/ProductPage.jsx
import { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router';
export default function ProductPage() {
const { id } = useParams(); // для /products/42 → id = '42'
const [product, setProduct] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
setProduct(null);
setError(null);
fetch(`https://dummyjson.com/products/${id}`)
.then((res) => {
if (!res.ok) throw new Error('Товар не найден');
return res.json();
})
.then((data) => {
if (!ignore) setProduct(data);
})
.catch((err) => {
if (!ignore) setError(err.message);
});
return () => {
ignore = true;
};
}, [id]);
if (error) return <p>{error}</p>;
if (!product) return <p>Загрузка...</p>;
return (
<article>
<Link to="/products">← Назад в каталог</Link>
<h1>{product.title}</h1>
<img src={product.thumbnail} alt={product.title} width={240} />
<p>{product.description}</p>
<strong>${product.price}</strong>
</article>
);
}
- Имя в
useParamsсовпадает с именем в пути::id→id. Дляusers/:userId/posts/:postIdбудет{ userId, postId }. - Параметр всегда строка. Нужно число —
Number(id). idв зависимостях эффекта: при переходе с/products/1на/products/2компонент не пересоздаётся, но эффект перезапустится и загрузит новый товар.
А в каталоге сделаем ссылки на карточки:
// src/pages/ProductsPage.jsx (упрощённо)
{products.map((p) => (
<li key={p.id}>
<Link to={`/products/${p.id}`}>{p.title}</Link>
</li>
))}
useNavigate: переход из кода
Link — для ссылок, по которым кликают. А если нужно перейти после действия: после входа в аккаунт, после отправки формы, по кнопке «Назад»? Для этого хук useNavigate:
import { useNavigate } from 'react-router';
function LoginPage() {
const navigate = useNavigate();
async function handleSubmit(e) {
e.preventDefault();
// ... запрос на сервер для входа
navigate('/profile'); // переход после успешного входа
}
return (
<form onSubmit={handleSubmit}>
<input placeholder="Email" />
<button type="submit">Войти</button>
<button type="button" onClick={() => navigate(-1)}>Назад</button>
</form>
);
}
| Вызов | Что делает |
|---|---|
navigate('/profile') |
Переход на /profile |
navigate(-1) |
Назад по истории (как кнопка «Назад» в браузере) |
navigate(1) |
Вперёд по истории |
navigate('/login', { replace: true }) |
Переход с заменой текущей записи в истории — «Назад» не вернёт на эту страницу |
navigate(`/products/${id}`) |
Переход на динамический адрес |
useSearchParams: строка запроса
Помните адреса вида /products?category=phones&sort=price? Часть после ? — это строка запроса (query string, search params). В ней удобно хранить фильтры, поиск, номер страницы: таким адресом можно поделиться, и у другого человека откроются те же фильтры.
useSearchParams работает похоже на useState, только значения хранятся в адресе:
import { useSearchParams } from 'react-router';
const allProducts = [
{ id: 1, title: 'iPhone', category: 'phones' },
{ id: 2, title: 'Galaxy', category: 'phones' },
{ id: 3, title: 'MacBook', category: 'laptops' },
];
export default function ProductsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') ?? '';
const category = searchParams.get('category') ?? 'all';
function updateParam(key, value) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (value && value !== 'all') next.set(key, value);
else next.delete(key);
return next;
});
}
const visible = allProducts.filter(
(p) =>
p.title.toLowerCase().includes(query.toLowerCase()) &&
(category === 'all' || p.category === category)
);
return (
<div>
<input value={query} onChange={(e) => updateParam('q', e.target.value)} placeholder="Поиск" />
<select value={category} onChange={(e) => updateParam('category', e.target.value)}>
<option value="all">Все</option>
<option value="phones">Телефоны</option>
<option value="laptops">Ноутбуки</option>
</select>
<ul>
{visible.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
</div>
);
}
Разбор:
searchParams— объектURLSearchParams.get('q')возвращает строку илиnull, если параметра нет;?? ''подставляет значение по умолчанию.setSearchParamsменяет строку запроса — адрес становится, например,/products?q=mac&category=laptops, и компонент перерисовывается.- Передаём в
setSearchParamsфункцию и копируем старые параметры, чтобы при изменении поиска не потерять выбранную категорию. - Пустые значения удаляем (
next.delete), чтобы адрес оставался чистым. - Обновите страницу или отправьте ссылку другу — фильтры сохранятся.
useParams |
useSearchParams |
|
|---|---|---|
| Часть адреса | Путь: /products/42 |
После ?: ?q=mac&page=2 |
| Описывается в маршруте | Да, :id |
Нет |
| Обязательность | Без него маршрут не совпадёт | Необязательные |
| Для чего | Какой объект открыть | Фильтры, поиск, сортировка, пагинация |
lazy(): ленивая загрузка страниц
По умолчанию сборщик кладёт все страницы в один большой JavaScript-файл. Пользователь открыл главную — а скачал ещё и админку, и профиль, и корзину. Функция lazy из React позволяет загружать код страницы только когда она нужна:
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router';
import MainLayout from './layouts/MainLayout';
import HomePage from './pages/HomePage';
const AboutPage = lazy(() => import('./pages/AboutPage'));
const ProductsPage = lazy(() => import('./pages/ProductsPage'));
const ProductPage = lazy(() => import('./pages/ProductPage'));
function App() {
return (
<Suspense fallback={<p>Загрузка страницы...</p>}>
<Routes>
<Route path="/" element={<MainLayout />}>
<Route index element={<HomePage />} />
<Route path="products" element={<ProductsPage />} />
<Route path="products/:id" element={<ProductPage />} />
<Route path="about" element={<AboutPage />} />
</Route>
</Routes>
</Suspense>
);
}
export default App;
lazy(() => import('./pages/AboutPage'))— динамическийimport(): Vite вынесет страницу в отдельный файл и загрузит его при первом переходе на/about.- Страница для
lazyдолжна быть экспортирована черезexport default. Suspenseпоказываетfallback, пока код страницы скачивается.- Главную обычно импортируют обычным способом — она нужна сразу.
Проверьте: npm run build — в папке dist/assets появятся отдельные JS-файлы для ленивых страниц. А во вкладке Network видно, как файл страницы скачивается при первом переходе.
Способ 2. createBrowserRouter и RouterProvider
Начиная с версии 6.4 у React Router есть data router — маршруты описываются обычным массивом объектов, а не JSX. Это рекомендуемый способ для новых приложений: он даёт дополнительные возможности — загрузку данных до открытия страницы (loaders), обработку ошибок маршрута, формы (actions).
// src/router.jsx
import { createBrowserRouter } from 'react-router';
import MainLayout from './layouts/MainLayout';
import HomePage from './pages/HomePage';
import ProductsPage from './pages/ProductsPage';
import ProductPage from './pages/ProductPage';
import AboutPage from './pages/AboutPage';
import NotFoundPage from './pages/NotFoundPage';
import ErrorPage from './pages/ErrorPage';
export const router = createBrowserRouter([
{
path: '/',
element: <MainLayout />,
errorElement: <ErrorPage />,
children: [
{ index: true, element: <HomePage /> },
{ path: 'products', element: <ProductsPage /> },
{ path: 'products/:id', element: <ProductPage /> },
{ path: 'about', element: <AboutPage /> },
{ path: '*', element: <NotFoundPage /> },
],
},
]);
// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router';
import { router } from './router';
createRoot(document.getElementById('root')).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
);
Сравните с первым способом — структура та же:
{ path, element }↔<Route path element />;children: [...]↔ вложенные<Route>;index: true↔<Route index />;RouterProviderзаменяет иBrowserRouter, иRoutes—AppсRoutesбольше не нужен.
Link, NavLink, Outlet, useParams, useNavigate, useSearchParams работают одинаково в обоих способах.
errorElement
Если в компоненте страницы произошла ошибка, вместо белого экрана покажется errorElement. Узнать, что случилось, помогает хук useRouteError:
// src/pages/ErrorPage.jsx
import { useRouteError, Link } from 'react-router';
export default function ErrorPage() {
const error = useRouteError();
console.error(error);
return (
<div style={{ padding: 24 }}>
<h1>Что-то пошло не так</h1>
<p>{error?.statusText || error?.message}</p>
<Link to="/">На главную</Link>
</div>
);
}
Ленивые маршруты в data router
В createBrowserRouter у маршрута есть собственное свойство lazy — роутер сам загрузит код перед переходом, без Suspense. Модуль страницы для этого экспортирует компонент под именем Component:
// src/pages/AboutPage.jsx — добавляем именованный экспорт
export function Component() {
return <h1>О нас</h1>;
}
// в router.jsx
{ path: 'about', lazy: () => import('./pages/AboutPage') },
Привычный React.lazy + Suspense в element тоже работает и в data router.
Какой способ выбрать
BrowserRouter + Routes |
createBrowserRouter |
|
|---|---|---|
| Как описываются маршруты | JSX внутри компонентов | Массив объектов в отдельном файле |
| Загрузка данных (loader), actions | Нет | Да |
errorElement |
Нет | Да |
| Где встречается | Много существующих проектов | Рекомендуется для новых |
Загрузку данных через loader и хук useLoaderData подробно разберём в уроке SPA на React Router.
Типичные ошибки
- Хуки роутера вне роутера.
useNavigateв компоненте, который не лежит внутриBrowserRouter/RouterProvider, — ошибка «useNavigate() may be used only in the context of acomponent». <a href>вместо<Link to>— страница полностью перезагружается, состояние теряется.element={AboutPage}вместоelement={<AboutPage />}.- Слеш у дочернего пути (
/productsвнутри родителя) — путь станет абсолютным и может не совпасть с ожидаемым. - Забыли
<Outlet />в макете — вложенные страницы не отображаются. - Ждут число из
useParams— там всегда строка. - 404 после деплоя при обновлении страницы. Dev-сервер Vite сам отдаёт
index.htmlна любой адрес, а обычный хостинг — нет. На хостинге нужно настроить, чтобы все адреса отдавалиindex.html.
Практика
- Три страницы. Подключите React Router через
BrowserRouter: главная, «О нас», «Контакты» и страница 404. Ожидаемый результат: переходы по меню без перезагрузки, на неизвестном адресе — 404. - Макет и меню. Вынесите шапку с
NavLinkи подвал вMainLayoutсOutlet. Активный пункт меню подсвечивается. - Каталог и карточка. Страница
/productsзагружает список сhttps://dummyjson.com/products?limit=20, каждая позиция —Linkна/products/:id. Страница товара берётidизuseParamsи загружает товар. Добавьте кнопку «Назад» черезnavigate(-1). - Фильтры в адресе. Добавьте на страницу каталога поиск
?q=и сортировку?sort=price-asc|price-descчерезuseSearchParams. Ожидаемый результат: после обновления страницы фильтры сохраняются. - Переезд на data router. Перепишите маршруты на
createBrowserRouter+RouterProvider, добавьтеerrorElementи сделайте страницы «О нас» и «Контакты» ленивыми. Проверьте черезnpm run build, что они вынесены в отдельные файлы.
Итоги
- SPA — одна HTML-страница, «страницы» — компоненты; React Router связывает адреса с компонентами без перезагрузки.
- Способ 1:
BrowserRouter→Routes→Route path element; способ 2:createBrowserRouter([...])+RouterProviderсchildrenиerrorElement. - Для переходов —
LinkиNavLink(подсветка активного пункта), для общего каркаса — макет сOutlet. useParamsчитает параметры пути (/products/:id), значения всегда строки.useNavigateпереходит из кода:navigate('/path'),navigate(-1),{ replace: true }.useSearchParamsхранит фильтры и поиск в строке запроса, которой можно поделиться.lazy(() => import(...))+Suspenseзагружают код страниц по требованию; в data router есть свойство маршрутаlazy.