Урок 34 из 49 · Месяц 5. React и Zustand
SPA на React Router: данные между страницами и useLoaderData
Содержание урока
- SPA и MPA: в чём разница
- MPA — многостраничное приложение
- SPA — одностраничное приложение
- Подготовка проекта
- Data router: createBrowserRouter
- Описываем маршруты
- Подключаем роутер
- Общий каркас и Outlet
- Загрузка данных: loader и useLoaderData
- Зачем нужен loader
- Список пользователей
- Страница одного пользователя: params в loader
- Страница ошибки
- Передача данных между страницами
- Способ 1. Параметр пути
- Способ 2. Query-параметры и useSearchParams
- Способ 3. state навигации
- Способ 4. Глобальное состояние
- Типичные ошибки
- Практика
- Итоги
В этом уроке разберёмся, чем одностраничное приложение (SPA) отличается от многостраничного сайта (MPA), и соберём SPA на React Router. Научимся передавать данные между страницами: через параметры пути, строку запроса (useSearchParams) и state навигации. А ещё узнаем, как загружать данные до показа страницы с помощью loader и useLoaderData.
SPA и MPA: в чём разница
MPA — многостраничное приложение
MPA (Multi-Page Application) — классический сайт: каждая страница — отдельный HTML с сервера. Клик по ссылке — браузер загружает новый HTML и перерисовывает всё: экран моргает, введённый в поля текст теряется.
SPA — одностраничное приложение
SPA (Single-Page Application) — сервер отдаёт один index.html и JavaScript. При клике по ссылке JavaScript меняет адрес в строке браузера и подменяет только нужную часть страницы, без перезагрузки.
Аналогия: MPA — за каждой главой книги вы ходите в библиотеку, SPA — электронная читалка, где вы просто листаете экран.
| MPA | SPA | |
|---|---|---|
| Переход между страницами | Полная перезагрузка | Подмена части страницы без перезагрузки |
| Состояние при переходе | Теряется | Сохраняется (шапка, плеер, корзина) |
| SEO | Проще | Сложнее (нужны SSR или пререндер) |
| Примеры | Блоги, новостные сайты | Gmail, Trello, админки, личные кабинеты |
Подготовка проекта
Создадим проект через Vite и установим роутер:
npm create vite@latest my-spa -- --template react
cd my-spa
npm install
npm install react-router
npm run dev
Data router: createBrowserRouter
Современный способ описать маршруты — data router: создаём роутер функцией createBrowserRouter и передаём в RouterProvider. Он умеет ещё и загружать данные для страниц.
Описываем маршруты
// src/router.jsx
import { createBrowserRouter } from "react-router";
import RootLayout from "./layouts/RootLayout";
import HomePage from "./pages/HomePage";
import UsersPage, { usersLoader } from "./pages/UsersPage";
import UserPage, { userLoader } from "./pages/UserPage";
import ErrorPage from "./pages/ErrorPage";
export const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
errorElement: <ErrorPage />,
children: [
{ index: true, element: <HomePage /> },
{ path: "users", element: <UsersPage />, loader: usersLoader },
{ path: "users/:userId", element: <UserPage />, loader: userLoader },
],
},
]);
Разберём по частям:
path: "/"— корневой маршрут. Егоelement— общий каркас (шапка, меню), внутри которого меняются страницы.errorElement— что показать, если что-то сломалось: страница не найдена или loader выбросил ошибку.children— вложенные маршруты. Их адреса пишутся без ведущего/и считаются относительно родителя:users→/users.index: true— страница «по умолчанию» для родителя, то есть для адреса/.:userId— динамический параметр. Подойдёт/users/1,/users/42и т. д.loader— функция, которая загрузит данные до показа страницы.
Подключаем роутер
// 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>
);
Общий каркас и Outlet
// src/layouts/RootLayout.jsx
import { NavLink, Outlet, useNavigation } from "react-router";
export default function RootLayout() {
const navigation = useNavigation();
return (
<div>
<header>
<nav style={{ display: "flex", gap: 16 }}>
<NavLink to="/">Главная</NavLink>
<NavLink to="/users">Пользователи</NavLink>
</nav>
</header>
{navigation.state === "loading" && <p>Загрузка...</p>}
<main>
<Outlet />
</main>
</div>
);
}
<Outlet />— «окно», куда роутер подставит текущую дочернюю страницу. Шапка остаётся на месте.NavLink— ссылка, которая получает классactive, когда её адрес открыт.useNavigation()— пока loader следующей страницы грузит данные,navigation.stateравен"loading".
Загрузка данных: loader и useLoaderData
Зачем нужен loader
Раньше мы грузили данные в useEffect: страница сначала пустая, потом данные появляются. Минусы — «мигание» и много кода с useState для загрузки и ошибки.
Loader — функция, которую роутер вызывает до того, как показать страницу. Аналогия: официант приносит блюдо уже готовым, а не ставит пустую тарелку и уходит на кухню.
Список пользователей
// src/pages/UsersPage.jsx
import { Link, useLoaderData } from "react-router";
export async function usersLoader() {
const res = await fetch("https://jsonplaceholder.typicode.com/users");
if (!res.ok) {
throw new Response("Не удалось загрузить пользователей", { status: res.status });
}
return res.json();
}
export default function UsersPage() {
const users = useLoaderData();
return (
<ul>
{users.map((user) => (
<li key={user.id}>
<Link to={`/users/${user.id}`}>{user.name}</Link>
</li>
))}
</ul>
);
}
Что происходит:
- Клик на «Пользователи» — роутер вызывает
usersLoader. Пока идёт запрос, видна старая страница и «Загрузка...». - Loader вернул данные — роутер рисует
UsersPage, аuseLoaderData()отдаёт ровно то, что вернул loader. - Loader бросил ошибку (
throw) — вместо страницы покажетсяerrorElement.
Страница одного пользователя: params в loader
Loader получает объект с params (параметры пути) и request (сам запрос, из него можно достать URL):
// src/pages/UserPage.jsx
import { Link, useLoaderData } from "react-router";
export async function userLoader({ params }) {
const res = await fetch(
`https://jsonplaceholder.typicode.com/users/${params.userId}`
);
if (res.status === 404) {
throw new Response("Пользователь не найден", { status: 404 });
}
return res.json();
}
export default function UserPage() {
const user = useLoaderData();
return (
<article>
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
<p>Город: {user.address.city}</p>
<Link to="/users">← Назад к списку</Link>
</article>
);
}
params.userId — то, что стоит на месте :userId в адресе. Для /users/3 это строка "3".
Страница ошибки
// src/pages/ErrorPage.jsx
import { isRouteErrorResponse, Link, useRouteError } from "react-router";
export default function ErrorPage() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status}</h1>
<p>{error.data}</p>
<Link to="/">На главную</Link>
</div>
);
}
return <p>Что-то пошло не так: {error?.message}</p>;
}
useRouteError()достаёт ошибку, аisRouteErrorResponseпроверяет, что это нашthrow new Response(...)соstatusиdata.- Несуществующий адрес (
/abracadabra) тоже попадёт сюда со статусом 404.
Передача данных между страницами
Страницы — не родитель и ребёнок, пропсы между ними не передашь. Вот какие есть способы.
| Способ | Пример адреса | Когда использовать |
|---|---|---|
Параметр пути (useParams) |
/users/5 |
Идентификатор сущности: товар, пользователь, пост |
Query-параметры (useSearchParams) |
/products?category=phones&page=2 |
Фильтры, поиск, сортировка, пагинация |
state навигации (useLocation) |
адрес не меняется | Временные данные: «откуда пришли», сообщение об успехе |
| Глобальное состояние | — | Данные, нужные многим страницам: корзина, пользователь |
Способ 1. Параметр пути
Уже видели выше: /users/:userId. В компоненте параметр достаётся хуком useParams:
import { useParams } from "react-router";
function UserPage() {
const { userId } = useParams();
return <h1>Профиль пользователя №{userId}</h1>;
}
Способ 2. Query-параметры и useSearchParams
Query-параметры — часть адреса после ?: ?search=leanne&sort=name, пары «ключ=значение» через &. Плюс: состояние хранится в адресе, ссылку можно отправить другу — он увидит тот же поиск.
useSearchParams работает как useState, только хранит значение в адресе:
import { useSearchParams, useLoaderData, Link } from "react-router";
export default function UsersPage() {
const users = useLoaderData();
const [searchParams, setSearchParams] = useSearchParams();
const search = searchParams.get("search") ?? "";
const filtered = users.filter((u) =>
u.name.toLowerCase().includes(search.toLowerCase())
);
function handleChange(e) {
const value = e.target.value;
if (value) {
setSearchParams({ search: value });
} else {
setSearchParams({});
}
}
return (
<div>
<input value={search} onChange={handleChange} placeholder="Поиск по имени" />
<ul>
{filtered.map((u) => (
<li key={u.id}>
<Link to={`/users/${u.id}`}>{u.name}</Link>
</li>
))}
</ul>
</div>
);
}
Разбор:
searchParams— встроенный объектURLSearchParams..get("search")вернётnull, если параметра нет, поэтому?? "".setSearchParams({ search: value })меняет адрес на/users?search=valueи перерисовывает компонент;{}убирает все параметры.
Если параметров несколько и нужно поменять только один, используйте функцию-обновлятор:
setSearchParams((prev) => {
prev.set("page", "2");
return prev;
});
Query-параметры в loader
Loader тоже может читать строку запроса — через request.url. Тогда фильтрация произойдёт на сервере:
export async function usersLoader({ request }) {
const url = new URL(request.url);
const search = url.searchParams.get("search") ?? "";
// адрес вашего API, который умеет искать по параметру search
const res = await fetch(
`https://api.example.com/users?search=${encodeURIComponent(search)}`
);
return res.json();
}
encodeURIComponent делает пробелы и русские буквы безопасными для адреса. Когда вызывается setSearchParams, роутер заново запускает loader — данные обновятся сами.
Способ 3. state навигации
Данные, которые не должны быть в адресе (например, «Заказ оформлен!» после отправки формы), передают через опцию state у navigate или Link:
import { useNavigate } from "react-router";
function OrderForm() {
const navigate = useNavigate();
function handleSubmit(e) {
e.preventDefault();
// ...отправили заказ на сервер
navigate("/", { state: { message: "Заказ №123 оформлен!" } });
}
return (
<form onSubmit={handleSubmit}>
<button>Оформить</button>
</form>
);
}
Принимаем на другой странице через useLocation:
import { useLocation } from "react-router";
function HomePage() {
const location = useLocation();
const message = location.state?.message;
return (
<div>
{message && <p className="success">{message}</p>}
<h1>Главная</h1>
</div>
);
}
Способ 4. Глобальное состояние
Данные, нужные многим страницам (пользователь, корзина), кладут в общее хранилище — об этом уроки обзор менеджеров состояния и Zustand.
Типичные ошибки
- Ссылки через
<a href>— приложение перезагружается. ИспользуйтеLink. - Ведущий
/у дочерних маршрутов.path: "/users"внутриchildrenсделает путь абсолютным. Для вложенных пишитеpath: "users". - Забыли
<Outlet />в layout — дочерние страницы не отображаются. useLoaderData()в маршруте безloaderвернётundefined, иusers.mapупадёт.- Сравнение числа со строкой из адреса:
user.id === params.userIdвсегдаfalse. ИспользуйтеNumber(params.userId). - 404 на хостинге при обновлении страницы. Сервер ищет файл
/users/5и не находит — настройте хостинг отдаватьindex.htmlна все адреса (правило rewrites).
Практика
- Каркас SPA. Создайте проект на Vite с маршрутами
/,/aboutи/postsи общим layout с меню наNavLink. Ожидаемый результат: переходы без перезагрузки, активный пункт подсвечен классом.active. - Список постов через loader. Для
/postsнапишите loader, который загружаетhttps://jsonplaceholder.typicode.com/posts, и выведите заголовки черезuseLoaderData. Добавьте индикатор загрузки наuseNavigation. - Детальная страница. Сделайте маршрут
/posts/:postIdс loader, который загружает один пост. Для несуществующего поста выбросьтеResponseсо статусом 404 и покажите понятную страницу ошибки. - Поиск в адресе. Добавьте на
/postsполе поиска по заголовку, значение которого хранится в?search=. После обновления страницы поиск должен сохраняться. - Сообщение после действия. Сделайте страницу
/posts/newс формой. По кнопке «Сохранить» переходите на/postsсstate: { message: "Пост создан" }и показывайте это сообщение над списком.
Итоги
- SPA загружает один HTML и меняет содержимое без перезагрузки, MPA загружает новую страницу при каждом переходе.
- Маршруты описываются через
createBrowserRouterи подключаются черезRouterProvider; общий каркас — layout с<Outlet />. loaderзагружает данные до показа страницы,useLoaderDataих получает,errorElement+useRouteErrorобрабатывают ошибки.- Идентификаторы передаём в пути (
useParams), фильтры и поиск — в query-параметрах (useSearchParams). - Временные данные можно передать через
stateуnavigate/Linkи прочитать вuseLocation. - Все значения из адреса — строки.