Await и Suspense с отложенными данными

React Router начиная с версии 6.4 ввёл концепцию отложенных данных (deferred data), которая позволяет загружать данные асинхронно и интегрировать их с рендерингом компонентов с помощью Suspense. Это значительно упрощает работу с асинхронными источниками данных и улучшает пользовательский опыт за счёт управления состоянием загрузки прямо на уровне маршрутов.


Deferred и defer()

Функция defer() используется для создания объекта, содержащего данные, которые могут загружаться асинхронно. Она принимает объект с ключами, которые будут доступны в компоненте через хук useLoaderData(). Каждый ключ может быть как синхронным значением, так и промисом. Пример:

import { defer } from "react-router-dom";

export function loader() {
  return defer({
    user: fetchUser(), // промис
    settings: fetchSettings() // промис
  });
}

Ключевые моменты:

  • defer() не блокирует рендеринг компонента.
  • Промисы внутри объекта будут обработаны при помощи Await.
  • Позволяет разделять части данных, загружаемые синхронно и асинхронно.

Компонент Await

Await используется внутри компонентов, чтобы работать с промисами, возвращёнными из defer(). Он позволяет рендерить placeholder через Suspense до тех пор, пока данные не загрузятся.

import { useLoaderData, Await } from "react-router-dom";
import { Suspense } from "react";

export default function UserPage() {
  const data = useLoaderData();

  return (
    <div>
      <h1>Профиль пользователя</h1>
      <Suspense fallback={<p>Загрузка данных пользователя...</p>}>
        <Await resolve={data.user}>
          {(user) => (
            <div>
              <p>Имя: {user.name}</p>
              <p>Email: {user.email}</p>
            </div>
          )}
        </Await>
      </Suspense>
      <Suspense fallback={<p>Загрузка настроек...</p>}>
        <Await resolve={data.settings}>
          {(settings) => <pre>{JSON.stringify(settings, null, 2)}</pre>}
        </Await>
      </Suspense>
    </div>
  );
}

Особенности:

  • resolve принимает промис или отложенное значение.
  • Внутри Await можно использовать функцию, которая получает разрешённые данные.
  • Suspense обеспечивает отображение запасного UI, пока промис не завершён.

Интеграция с маршрутами

Асинхронные данные удобно интегрировать с системой маршрутов. В React Router маршруты поддерживают loader и defer одновременно. Пример конфигурации маршрутов:

import { createBrowserRouter } from "react-router-dom";
import UserPage, { loader as userLoader } from "./UserPage";

const router = createBrowserRouter([
  {
    path: "/user/:id",
    element: <UserPage />,
    loader: userLoader
  }
]);

При навигации:

  1. Маршрут вызывает loader.
  2. loader возвращает объект с defer().
  3. Компонент получает данные через useLoaderData().
  4. Отдельные части данных обрабатываются через Await с Suspense.

Комбинирование нескольких промисов

Await поддерживает массивы и объекты промисов одновременно:

<Suspense fallback={<p>Загрузка...</p>}>
  <Await resolve={Promise.all([fetchPosts(), fetchComments()])}>
    {([posts, comments]) => (
      <div>
        <h2>Посты</h2>
        {posts.map(post => <p key={post.id}>{post.title}</p>)}
        <h2>Комментарии</h2>
        {comments.map(comment => <p key={comment.id}>{comment.text}</p>)}
      </div>
    )}
  </Await>
</Suspense>

Преимущества:

  • Можно загружать несколько ресурсов параллельно.
  • Отдельные части интерфейса рендерятся независимо.
  • UI остаётся отзывчивым даже при больших объёмах данных.

Использование отложенных данных с ErrorBoundary

Чтобы обрабатывать ошибки асинхронных промисов, Await поддерживает компонент ErrorBoundary или можно использовать catch на промисе. Пример с обработкой ошибки:

<Suspense fallback={<p>Загрузка...</p>}>
  <Await 
    resolve={data.user} 
    errorElement={<p>Ошибка при загрузке пользователя</p>}
  >
    {(user) => <p>{user.name}</p>}
  </Await>
</Suspense>

Ключевые моменты:

  • errorElement отображается, если промис завершился с ошибкой.
  • Можно создавать отдельные boundaries для каждой части данных.
  • Повышает стабильность и предсказуемость интерфейса.

Практические советы

  1. Минимизировать количество Suspense: объединять несколько небольших элементов в один блок, если они загружаются параллельно.
  2. Использовать defer для тяжёлых данных: крупные API-запросы, которые не нужны для начального рендера.
  3. Разделять загрузку UI и данных: показывать интерфейс сразу, а данные подгружать частями.
  4. Обрабатывать ошибки на уровне маршрута: для глобальной обработки можно использовать errorElement маршрута.

Эта комбинация defer, Await и Suspense позволяет строить высоко отзывчивые интерфейсы с асинхронными данными, сокращает время до первого рендера и делает код более читаемым и модульным.