Как работает загрузка данных в Remix 3
Узнайте, как Remix 3 заменяет loaders действиями контроллеров для загрузки данных и как работают маршруты, потоковые Frames, состояния ожидания и ошибки.
В Remix 3 данные страницы загружаются внутри действия контроллера (controller action), которое обрабатывает GET-маршрут. Это действие возвращает стандартный веб-объект Response, как правило созданный с помощью context.render().
Если вы уже выпускали приложения на Remix 2, то первым делом наверняка станете искать экспорт loader и хук, который его читает. В новых руководствах их нет, а старые публикации, утверждающие, что загрузчики перекочевали в Remix 3, расходятся с документацией Remix 3.
Remix 3 (v3.0.0), вышедший 1 октября 2026 года, использует модель, отличную от подхода с загрузчиками из Remix 1/2, описанного в этом обзоре исходной архитектуры Remix на основе загрузчиков. В этой статье разберём, где теперь происходит получение данных, как выполняется потоковая передача при первой загрузке и навигации, как устроен UI ожидания и куда попадают ошибки.
Ключевые выводы
- Remix 3 получает данные страницы внутри действия
createController()для GET-маршрута, а действие передаёт эти данные в виде props компоненту, который отрисовывается черезcontext.render(). - Маршруты объявляются в
app/routes.ts, реализуются контроллерами и регистрируются с помощьюrouter.map(). Каждый контроллер отвечает только за непосредственные листья своей карты маршрутов. Frameбезfallbackзадерживает отправку начального HTML, пока его маршрут не ответит.Frameсfallbackсначала передаёт в потоке fallback и позднее заменяет его.- Ожидаемые сбои, например отсутствующие записи, следует возвращать как объекты
Responseсо статусом 404 или 400. Выброшенное исключение приводит к отклонению промисаrouter.fetch(), и серверная граница возвращает 500. - Если отложенный фрейм завершается с ошибкой после того, как его fallback уже отправлен, ошибка передаётся в механизмы отчётности, а fallback остаётся на экране.
Что заменило загрузчик в Remix 3?
В Remix 3 работу, которую раньше выполнял загрузчик маршрута, делает действие контроллера. Карта маршрутов объявляет URL и HTTP-метод, а createController() привязывает по одному действию к каждому листу маршрута. Действие считывает типизированные значения из context.params, дожидается данных и возвращает Response. Этот паттерн показан в руководстве Routing and Controllers, и экспорты загрузчиков в нём не встречаются вовсе.
// app/actions/albums/controller.tsx
import { createController } from "remix/router";
import { routes } from "../../routes.ts";
// getAlbum and getRecommendations are app data functions, not Remix APIs.
import { getAlbum, getRecommendations } from "./data.ts";
import { AlbumRecommendations } from "./recommendations.tsx";
import { AlbumPage } from "./show-page.tsx";
export default createController(routes.albums, {
actions: {
async show(context) {
// The second argument is an app-defined signal parameter.
let album = await getAlbum(context.params.albumId, context.request.signal);
if (!album) return new Response("Album not found", { status: 404 });
return context.render(<AlbumPage album={album} />);
},
async recommendations(context) {
let album = await getAlbum(context.params.albumId, context.request.signal);
if (!album) return new Response("Album not found", { status: 404 });
let recommendations = await getRecommendations(album);
return context.render(<AlbumRecommendations albums={recommendations} />);
},
},
});
Главное отличие от Remix 2 — направление потока данных. В Remix 2 компонент сам «вытягивал» данные через хук. В Remix 3 действие «проталкивает» данные в компонент в виде props. context.render доступен только в том случае, если на роутере установлен middleware render(). Без него действие по-прежнему может вернуть обычный Response или Response.json().
Где происходит загрузка данных в Remix 3?
Приложение на Remix 3 объявляет свои URL и HTTP-методы в app/routes.ts, реализует по одному действию на каждый лист маршрута с помощью createController() и регистрирует каждый контроллер через router.map().
// app/routes.ts
import { get, route } from "remix/routes";
export const routes = route({
albums: route("/albums", {
show: get("/:albumId"),
recommendations: get("/:albumId/recommendations"),
}),
});
routes.albums.show.href({ albumId: "thriller" }); // "/albums/thriller"
Хелпер href() у каждого маршрута типизирован. Поскольку :albumId — переменная пути, albumId является обязательным аргументом. Если позже вы переименуете паттерн, любой вызов со старым ключом не пройдёт проверку типов.
// app/router.ts
import { createRouter } from "remix/router";
import albumsController from "./actions/albums/controller.tsx";
import { render } from "./middleware/render.tsx"; // the app's own render middleware, built with renderWith()
import { routes } from "./routes.ts";
export const router = createRouter({
middleware: [render()],
});
router.map(routes.albums, albumsController);
Контроллер отвечает только за непосредственные листья переданной ему карты, поэтому для вложенных карт нужен отдельный вызов router.map(). Remix проверяет каждое сопоставление на этапе инициализации: если у контроллера нет действия для одного из его листьев, инициализация завершится исключением ещё до обработки первого запроса. На практике закладывайте по одному GET-маршруту на страницу плюс по одному на каждую область, которая загружается самостоятельно.
Как работают первая загрузка и навигация в Remix 3?
При первой загрузке middleware рендеринга в Remix 3 передаёт в потоке один HTML-документ. При навигации, после запуска run() в браузере, ссылки и формы с того же источника (same-origin) могут заново заполнять отдельный именованный Frame вместо замены всего документа. Если области нужен собственный запрос, страница отрисовывает её с помощью Frame, указывающего на GET-маршрут этой области. Действие за этим маршрутом возвращает только разметку области, без оболочки документа приложения.
// app/actions/albums/show-page.tsx
import { Frame } from "remix/component";
import type { Handle } from "remix/component";
import { routes } from "../../routes.ts";
import { Document } from "../document.tsx"; // app-defined shell component
import type { Album } from "./data.ts";
export function AlbumPage(handle: Handle<{ album: Album }>) {
return () => {
let { album } = handle.props;
return (
<Document title={album.title}>
<h1>{album.title}</h1>
<Frame
name="album-recommendations"
src={routes.albums.recommendations.href({ albumId: album.id })}
fallback={<p>Loading recommendations…</p>}
/>
</Document>
);
};
}
Frame без fallback является блокирующим: Remix не отправляет первый фрагмент HTML, пока маршрут фрейма не ответит, поэтому страница приходит целиком. Если добавить fallback, он будет отправлен в составе начального HTML, а затем заменён результатом маршрута фрейма, когда соответствующее действие завершится. Выбирайте блокирующий вариант, когда без этой области страница не имеет смысла, и fallback — когда остальную часть страницы стоит показать раньше.
После запуска run() в браузере same-origin ссылки и формы могут заполнять именованный фрейм вместо навигации по всему документу. Эти атрибуты описаны в руководстве Streaming UI with Frames:
| Атрибут | Действие |
|---|---|
data-rmx-target | Выбирает смонтированный именованный фрейм |
data-rmx-src | Задаёт URL запроса, который заполняет фрейм |
data-rmx-reset-scroll="false" | Сохраняет текущую позицию прокрутки |
data-rmx-history="push|replace" | Переопределяет поведение истории по умолчанию |
data-rmx-document | Исключает ссылку или форму из перехвата |
У ссылки адресная строка по-прежнему переходит на href, а data-rmx-src определяет, что именно загружается во фрейм.
<form
action={routes.albums.recommendations.href({ albumId: album.id })}
method="get"
data-rmx-target="album-recommendations"
>
<button type="submit">Refresh recommendations</button>
</form>
Компоненты также могут перезагружать фреймы самостоятельно. handle.frame.reload() перезагружает фрейм, в котором находится компонент, а await handle.frames.get("album-recommendations")?.reload() обращается к именованному фрейму в другом месте страницы. Если фрейм с таким именем не смонтирован, handle.frames.get() возвращает undefined. Маршруты фреймов должны возвращать только доверенный HTML, поэтому любой недоверенный контент предварительно очищайте на сервере.
Состояния ожидания: fallback и отмена запросов
В Remix 3 UI ожидания реализуется через fallback у Frame и отмену запросов. Fallback у Frame служит начальным индикатором загрузки, а глобальный хук ожидания навигации в руководствах не описан.
Отмену запросов настраивает само приложение. Браузерный резолвер фреймов, который вы передаёте в run(), получает сигнал, и руководства рекомендуют передавать его в fetch(), чтобы запрос фрейма прерывался, когда фрейм удаляется или его вытесняет более новый запрос. На сервере middleware рендеринга передаёт request.signal в renderToStream() и в запросы своих фреймов. Чтобы отмена доходила до вашего собственного кода, передавайте request.signal также в обращения к базе данных и вызовы fetch. Согласно руководству Errors and Cancellation, при прерывании запроса роутер отклоняет промис с request.signal.reason.
Состояния ошибок: ответы и хуки отчётности
В Remix 3 отсутствующую запись или некорректный ввод следует возвращать как Response со статусом 404 или 400. Выброшенное исключение приводит к отклонению промиса router.fetch(), после чего серверная граница отвечает статусом 500. Универсальной компонентной границы ошибок (error boundary) в Remix 3 нет — каждый тип сбоя направляется в своё место:
| Сбой | Куда направляется |
|---|---|
| Ожидаемый (отсутствующая запись, некорректный ввод) | Возвращаемый Response со статусом 404 или 400 |
| Исключение в действии или middleware | Промис router.fetch() отклоняется; серверная граница сообщает об ошибке и возвращает 500 |
| Сбой рендеринга корня или блокирующего фрейма | Ошибка передаётся в отчётность, поток отклоняется |
| Сбой отложенного фрейма после отправки fallback | Ошибка передаётся в отчётность; fallback остаётся на странице |
| Браузерная среда выполнения (гидратация, компонент, планировщик, фрейм) | Событие error на значении, возвращаемом run() |
На серверной границе createRequestListener из remix/node-fetch-server принимает опцию onError. Именно здесь логируются неперехваченные ошибки; кроме того, она может вернуть Response, который будет отправлен вместо стандартного ответа 500. Опция onError у middleware рендеринга и браузерное событие error служат только для отчётности об ошибках. Для фреймов собственный резолвер приложения решает, во что превратится неуспешный ответ: в ограниченный HTML для этой области или в ошибку, передаваемую в упомянутые хуки отчётности. Резолвер из руководств выбрасывает исключение при любом статусе, отличном от OK, поэтому о каждом неудачном запросе фрейма поступает сообщение.
Если отложенный фрейм завершается с ошибкой после того, как его fallback уже отправлен, сервер не может заменить страницу ответом с ошибкой, поскольку байты тела ответа уже переданы клиенту. Ошибка передаётся в отчётность, а fallback остаётся на экране. Серверный лог зафиксирует эту ошибку, но не покажет, что видел пользователь. Этот пробел закрывает session replay: вы увидите, как пользователи смотрят на так и не исчезнувшее сообщение «Loading…», продолжая работать с остальной частью страницы. Пишите fallback так, чтобы зависшая область оставалась понятной, даже если она так и не заполнится.
Миграция с Remix 2
Перевод приложения с Remix 2 на Remix 3 — это переписывание, а не обновление. Маршруты переезжают в типизированную карту маршрутов, загрузка данных — в действия контроллеров, а UI — на собственную компонентную среду выполнения Remix из remix/component. Раздела о миграции в руководствах нет. Путь обновления «на месте» для Remix 2 — это React Router v7, а Remix 3 — отдельная кодовая база. Материалы вроде этого руководства по переходу с Next.js на Remix, написанного для Remix 1/2, рассматривайте лишь как справочный контекст, а не как руководство по Remix 3.
Заключение
Загрузка данных в Remix 3 сводится к следующему: GET-маршрут, действие контроллера, которое дожидается данных и возвращает Response, и фреймы для областей, которые загружаются или завершаются с ошибкой самостоятельно. Чтобы начать, опишите маршруты одной страницы в app/routes.ts, перенесите получение её данных в контроллер и решите, каким областям нужен fallback. Подробнее о работе с новым фреймворком читайте в статье о том, как Remix 3 спроектирован с расчётом на AI-агентов для написания кода.
Часто задаваемые вопросы
Использует ли Remix 3 React?
Нет. Remix 3 сохраняет синтаксис JSX, но не работает на React или React Router. Он использует собственную компонентную модель, экспортируемую из remix/component. Компонент состоит из внешней функции настройки, которая получает handle и выполняется один раз, и внутренней функции рендеринга, которая выполняется при каждом обновлении. Хуки React, такие как useState, не входят в эту модель, поэтому библиотеки компонентов React без доработки в Remix 3 не работают.
Как работают отправка форм и мутации в Remix 3?
Объявите форму с помощью хелпера маршрутов form() в app/routes.ts. По умолчанию он создаёт два листа по одному и тому же URL: index-маршрут GET, который отображает форму, и action-маршрут POST, который обрабатывает отправку. Оба листа принадлежат одному контроллеру. После успешного создания, обновления или удаления руководства Remix 3 рекомендуют возвращать редирект 303, чтобы обновление страницы не приводило к повторной отправке формы.
Может ли действие контроллера в Remix 3 возвращать JSON вместо HTML?
Да. Действие контроллера может вернуть любой веб-объект Response, включая Response.json(), редиректы, файлы и потоки. Руководство Remix 3 Forms and Mutations предлагает использовать JSON для таких сценариев, как автодополнение, поллинг, автономные виджеты, API и взаимодействие между сервисами. При этом оно не рекомендует добавлять отдельный JSON-путь для мутаций в форму, которая уже работает через HTML-ответ и редирект.
Remix 3 — это то же самое, что React Router v7?
Нет. Remix v2 был объединён с React Router v7, вышедшим в ноябре 2024 года, в качестве его Framework Mode. Это путь обновления для приложений на Remix 2, которые хотят сохранить загрузчики и React. Remix 3 — отдельный проект, анонсированный в мае 2025 года и построенный на собственной компонентной модели. Переход на него означает переписывание маршрутов, загрузки данных и UI.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k