React Router 8, вложенные маршруты, загрузчики, действия, интерфейс ожидания, границы ошибок и типизированные URL.
Маршрут — это граница данных, ошибок, доступа и истории, а не просто условие по пути URL.
Вы построите вложенное дерево маршрутов с загрузчиками и действиями, типизированными параметрами, состояниями ожидания и ошибки и отменяемой навигацией.
Понадобятся асинхронные границы, действия, формы и семантика URL. Итог урока — рабочее место с вложенными маршрутами, фильтрами в URL, изменением данных и восстановлением после ошибок.
Вложенные маршруты отражают устойчивые границы разметки, данных и ошибок. Родитель отрисовывает Outlet для дочернего маршрута. Плоский список абсолютных путей дублирует каркас страницы и усложняет владение данными. Индексный маршрут (index route) представляет дочернюю страницу по умолчанию без нового сегмента URL.
Конфигурация читается как дерево владения: боковая панель и справочники живут у родителя, детали заявки — у потомка:
// src/routes.ts
export default [
route('tickets', 'routes/tickets/layout.tsx', [
index('routes/tickets/index.tsx'), // /tickets — список по умолчанию
route(':ticketId', 'routes/tickets/detail.tsx'), // /tickets/42
]),
] satisfies RouteConfig;Родительский элемент отрисовывает общий каркас один раз и оставляет место потомку:
// src/routes/tickets/layout.tsx
export default function TicketsLayout() {
const { queues } = useLoaderData<typeof loader>();
return (
<div className="tickets">
<QueueSidebar queues={queues} />
<Outlet /> {/* здесь появится index или detail */}
</div>
);
}Без Outlet потомок просто не отрисуется: React Router не «вставляет» его автоматически, а ждёт явного места в разметке родителя.
Проверьте себя. Соберите раздел заявок с индексной страницей и дочерним маршрутом деталей.
Частая ошибка. Дублировать боковую панель во всех элементах маршрутов.
Параметры URL приходят строками или отсутствуют. UUID, число и перечисление проверяют до вызова предметного клиента. Генерация типов помогает с именами, но не доказывает корректность значения во время выполнения. Неверный идентификатор обычно превращают в ответ маршрута 400 или 404, а не в общий сбой.
Разбор параметра — это отдельный шаг между URL и предметным кодом, и у него два разных отрицательных исхода:
// src/routes/tickets/detail.ts
export async function loader({ params }: Route.LoaderArgs) {
const id = Number(params.ticketId);
if (!Number.isInteger(id) || id <= 0) {
// строка вообще не является идентификатором — это ошибка запроса
throw new Response('Некорректный идентификатор заявки', { status: 400 });
}
const ticket = await fetchTicket(id);
if (ticket === null) {
// идентификатор корректен, записи нет — это другой исход
throw new Response('Заявка не найдена', { status: 404 });
}
return { ticket };
}Number('abc') даёт NaN, Number('') — ноль, а Number('1.5') — нецелое: одна проверка Number.isInteger закрывает все три случая. Различие 400 и 404 важно не только для семантики HTTP — граница ошибок покажет разные сообщения и разные действия пользователю.
Проверьте себя. Разделите неверный числовой идентификатор и отсутствующую запись на разные исходы.
Частая ошибка. Number(param) без проверки NaN/целого/диапазона.
Загрузчик (loader) принадлежит маршруту и запускается при навигации. Данные родителя и потомка могут загружаться параллельно; не ждите независимые источники последовательно. Request содержит AbortSignal, который передают в fetch. Компонент читает готовый результат или отложенный ресурс.
Независимые справочники запускают одновременно, а сигнал отмены пробрасывают в каждый запрос:
// src/routes/tickets/layout.ts
export async function loader({ request }: Route.LoaderArgs) {
const [queues, operators] = await Promise.all([
fetchQueues({ signal: request.signal }),
fetchOperators({ signal: request.signal }),
]);
return { queues, operators };
}Последовательный await на двух независимых запросах превратил бы 120 мс в 240 мс без всякой пользы. request.signal прерывается, когда пользователь уходит с маршрута до ответа, — иначе браузер продолжает держать соединение и применяет результат, который уже никому не нужен.
Проверьте себя. Запустите независимые справочники через Promise.all и передайте request.signal.
Частая ошибка. Повторить запрос загрузчика в эффекте компонента.
Действие маршрута (action) изменяет данные из формы или fetcher. После него маршрутизатор повторно запускает загрузчики, возвращая интерфейс к данным сервера. Ошибка ввода отличается от перенаправления, успеха и системного сбоя. Form сохраняет нативную семантику, а fetcher выполняет действие без навигации.
Действие получает FormData, проверяет её и возвращает либо ошибку для отображения, либо признак успеха:
// src/routes/tickets/detail.ts
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
const status = formData.get('status');
if (status !== 'open' && status !== 'closed') {
return { error: 'Недопустимый статус заявки' }; // ошибка ввода, не исключение
}
await updateTicketStatus(Number(params.ticketId), status);
return { ok: true }; // загрузчики маршрута перезапустятся автоматически
}Возврат обычного объекта — это ошибка ввода, которую компонент покажет рядом с полем. throw new Response(...) — системный сбой, который уйдёт в границу ошибок. Ручного обновления данных на клиенте здесь нет: перезапуск загрузчиков возвращает интерфейс к состоянию сервера.
Проверьте себя. Измените статус через действие и подтвердите обновление данных загрузчика.
Частая ошибка. Изменять объект данных загрузчика в браузере без правила повторной загрузки или кеша.
Состояние навигации показывает загрузку, отправку и данные формы. Общий индикатор полезен при смене маршрута, локальный — для конкретной операции. Не заменяйте всю страницу индикатором при каждой навигации: сохраняйте разметку и, где безопасно, прежние данные.
fetcher знает, какие именно данные он отправляет, поэтому индикатор можно привязать к конкретной строке таблицы:
// src/features/tickets/TicketRow.tsx
function TicketRow({ ticket }: { ticket: Ticket }) {
const fetcher = useFetcher();
const submittedId = fetcher.formData?.get('ticketId');
const isSaving = fetcher.state !== 'idle' && submittedId === String(ticket.id);
return (
<tr aria-busy={isSaving}>
<td>{ticket.title}</td>
<td>
<fetcher.Form method="post">
<input type="hidden" name="ticketId" value={ticket.id} />
<button disabled={isSaving}>{isSaving ? 'Сохраняем…' : 'Закрыть'}</button>
</fetcher.Form>
</td>
</tr>
);
}Проверка идентификатора из formData обязательна: без неё все строки одновременно перейдут в состояние сохранения, потому что fetcher.state — общий для компонента.
Проверьте себя. Покажите состояние отправки только у изменяемой строки, используя идентификатор из formData.
Частая ошибка. Блокировать всё приложение из-за фоновой операции fetcher.
Граница маршрута локализует сбой загрузчика, действия или отрисовки. Ожидаемые «не найдено» и «нет доступа» выражают через Response и статус, а непредвиденную ошибку записывают с идентификатором связи (correlation id), не показывая пользователю стек. Родительская граница сохраняет каркас страницы, дочерняя — соседние маршруты.
Граница различает брошенный Response и неожидаемое исключение — это два разных сообщения и два разных действия:
// src/routes/tickets/detail.tsx
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (isRouteErrorResponse(error)) {
return error.status === 404
? <EmptyState title="Заявка не найдена" action={<Link to="/tickets">К списку</Link>} />
: <ErrorState title={`Ошибка ${error.status}`} />;
}
// непредвиденный сбой: пользователю — код обращения, в журнал — детали
return <ErrorState title="Не удалось загрузить заявку" hint={`Код обращения: ${getTraceId()}`} />;
}Стек и текст исключения не попадают в разметку: они могут содержать внутренние адреса, имена таблиц и фрагменты запросов. Пользователю достаточно кода обращения, по которому инженер найдёт запись в журнале.
Проверьте себя. Разделите 404 заявки и 503 backend failure.
Частая ошибка. Возвращать 200 с текстом ошибки при любом сбое загрузчика.
Параметры поиска подходят для фильтров, сортировки, страниц и вкладок, которые должны переживать обмен ссылкой, перезагрузку и возврат назад. Разбор задаёт значения по умолчанию и каноническую сериализацию; неизвестные значения не должны приводить к сбою. Временное наведение или секретный черновик в URL не помещают.
Разбор фильтров лучше держать одной функцией: она задаёт значения по умолчанию и не падает на мусоре в адресе:
// src/features/tickets/filters.ts
const STATUSES = ['all', 'open', 'closed'] as const;
type Status = (typeof STATUSES)[number];
export function parseFilters(url: URL): { status: Status; page: number } {
const rawStatus = url.searchParams.get('status');
const status = STATUSES.includes(rawStatus as Status) ? (rawStatus as Status) : 'all';
const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
return { status, page };
}?status=deleted&page=-3 не приведёт к сбою: неизвестный статус схлопывается в all, отрицательная страница — в первую. Обратная сериализация должна быть канонической, иначе один и тот же фильтр даст два разных URL и сломает кеш и историю.
Проверьте себя. Сериализуйте фильтры детерминированно и проверьте переходы назад и вперёд.
Частая ошибка. Дублировать фильтр в URL и локальном состоянии с эффектами в обе стороны.
Декларативный режим даёт клиентскую маршрутизацию, режим данных добавляет загрузчики и действия, а режим фреймворка — соглашения, генерацию типов, SSR и интеграцию со сборкой. Выбирайте минимальный режим, покрывающий требования отрисовки и размещения. Маршрутизатор не заменяет серверную авторизацию.
Последнее утверждение стоит проверить на конкретном примере. Такой загрузчик выглядит как защита доступа, но ею не является:
// ❌ проверка только на клиенте: данные уже пришли в браузер
export async function clientLoader() {
const user = readUserFromLocalStorage();
if (user?.role !== 'admin') throw redirect('/403');
return fetchAllTickets(); // токен и запрос доступны из консоли браузера
}Перенаправление скрывает интерфейс, но не запрещает запрос: пользователь вызовет тот же адрес напрямую. Решение о доступе принимает сервер, а маршрутизатор лишь отражает это решение в интерфейсе.
Проверьте себя. Кратко обоснуйте выбор режима данных или фреймворка для приложения.
Частая ошибка. Добавить режим фреймворка только ради одного Link, не оценив развёртывание.
Создайте /tickets?status= и /tickets/:ticketId: родительский загрузчик получает справочники, дочерний — заявку, действие меняет статус, навигация показывает ожидание, неверный идентификатор даёт границу 404, а история восстанавливает фильтры.
Работа готова, когда прямая ссылка работает после перезагрузки, загрузчики не дублируются эффектами, переходы назад и вперёд восстанавливают URL, а ошибки локализованы границей маршрута.
Далее: Архитектура состояния и данных