Dev Highlights

Доки по разработке

This project is maintained by teniryte

Suspense и Concurrent‑возможности React

Здесь собраны ключевые идеи React 18: concurrent rendering, Suspense, стратегии кеширования данных, transitions, оптимистичный UI, useDeferredValue и работа с тяжёлыми списками.

Concurrent rendering: общая идея

Concurrent rendering — это способ работы React, при котором рендеринг можно прерывать, откладывать и объединять:

Вы напрямую не управляетесь планировщиком — React делает это сам. Вы лишь помечаете обновления как срочные или нет:

Базовый паттерн Suspense

Suspense позволяет показывать запасной UI, пока часть дерева “занята” — загружает данные, код или что‑то ещё. Ключевая идея: компонент может “бросить” Promise, и пока он не выполнится, React будет использовать fallback.

Пример: ожидание данных

// Имитация асинхронной загрузки
function fetchUsers(): Promise<Array<{ id: number; name: string }>> {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve([
        { id: 1, name: 'Alice' },
        { id: 2, name: 'Bob' },
      ]);
    }, 1500);
  });
}

const usersPromise = fetchUsers();

function UsersList() {
  const users = use(usersPromise); // use "останавливает" компонент, пока промис не выполнится

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

export function UsersPage() {
  return (
    <Suspense fallback={<div>Загружаем пользователей...</div>}>
      <UsersList />
    </Suspense>
  );
}

Пока fetchUsers не вернул результат, React показывает fallback. Когда промис завершится — дерево “размораживается”, и UsersList отрисовывается с данными.

Suspense + обработка ошибок

Чтобы не только ждать данные, но и корректно обрабатывать ошибки, добавляют Error Boundary.

function ErrorFallback({ error }: { error: Error }) {
  return (
    <div role="alert">
      <p>Ошибка загрузки:</p>
      <pre>{error.message}</pre>
    </div>
  );
}

async function fetchUser(id: number) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error('Failed to load user');
  return res.json() as Promise<{ id: number; name: string }>;
}

const userPromise = fetchUser(1);

function UserInfo() {
  const user = use(userPromise);
  return <div>User: {user.name}</div>;
}

export function PageWithErrorBoundary() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <Suspense fallback={<div>Загрузка...</div>}>
        <UserInfo />
      </Suspense>
    </ErrorBoundary>
  );
}

Кеширование данных для Suspense

Чтобы не загружать одни и те же данные много раз, используют простые кеши:

Идея: кешировать промис загрузки, а не результат. Тогда:

type User = { id: number; name: string };

const userPromiseCache = new Map<number, Promise<User>>();

function fetchUserImpl(id: number): Promise<User> {
  return fetch(`/api/users/${id}`).then(res => {
    if (!res.ok) throw new Error('Failed');
    return res.json() as Promise<User>;
  });
}

function getUserPromise(id: number): Promise<User> {
  let promise = userPromiseCache.get(id);
  if (!promise) {
    promise = fetchUserImpl(id);
    userPromiseCache.set(id, promise);
  }
  return promise;
}

function UserItem({ id }: { id: number }) {
  const user = use(getUserPromise(id));
  return <div>{user.name}</div>;
}

export function UsersListCached() {
  return (
    <Suspense fallback={<div>Загрузка...</div>}>
      <UserItem id={1} />
      <UserItem id={2} />
      <UserItem id={1} /> {/* повторно использует кеш */}
    </Suspense>
  );
}

Вложенный Suspense

Иногда нужно, чтобы одна часть UI загружалась независимо от другой. Например:

function UsersMasterDetail() {
  const [selectedId, setSelectedId] = useState<number | null>(null);
  const users = use(fetchUsers()); // список пользователей

  return (
    <div>
      {users.map(user => (
        <button key={user.id} onClick={() => setSelectedId(user.id)}>
          {user.name}
        </button>
      ))}

      <Suspense fallback={<div>Загрузка деталей...</div>}>
        {selectedId && <UserItem id={selectedId} />}
      </Suspense>
    </div>
  );
}

export function NestedSuspensePage() {
  return (
    <Suspense fallback={<div>Загрузка списка...</div>}>
      <UsersMasterDetail />
    </Suspense>
  );
}

Здесь внешний Suspense отвечает за загрузку списка, внутренний — за детали выбранного пользователя.

Transitions (useTransition и startTransition)

Transitions позволяют помечать обновления как “не срочные”. Это удобно, когда:

Мы не хотим задерживать ввод ради списка — поэтому обновление списка помечается как transition.

export function UsersWithTransition({ users }: { users: User[] }) {
  const [filter, setFilter] = useState('');
  const [visibleUsers, setVisibleUsers] = useState<User[]>(users);
  const [isPending, startTransition] = useTransition();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
    setFilter(value); // срочное обновление — текст ввода

    startTransition(() => {
      // менее срочное — фильтрация большого списка
      setVisibleUsers(
        users.filter(user =>
          user.name.toLowerCase().includes(value.toLowerCase()),
        ),
      );
    });
  }

  return (
    <div>
      <input value={filter} onChange={handleChange} />
      {isPending && <div>Обновляем список...</div>}
      <ul style=>
        {visibleUsers.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

Пока transition не завершён, isPending будет true, и вы можете визуально подсветить “промежуточное” состояние.

Оптимистичный UI (useOptimistic)

Оптимистичный UI показывает результат действия ещё до того, как сервер его подтвердит. Это делает интерфейс отзывчивым, особенно при медленном соединении.

Простой список чисел

export function OptimisticNumbers() {
  const [numbers, setNumbers] = useState<number[]>([]);
  const [isPending, startTransition] = useTransition();

  const [optimisticNumbers, addOptimisticNumber] = useOptimistic<
    number[],
    number
  >(numbers, (current, newNumber) => [...current, newNumber]);

  async function handleAddNumber(newNumber: number) {
    // Сразу показываем число в списке
    addOptimisticNumber(newNumber);

    startTransition(async () => {
      // имитация запроса к серверу
      await new Promise(resolve => setTimeout(resolve, 1000));
      setNumbers(prev => [...prev, newNumber]);
    });
  }

  return (
    <div>
      <ul>
        {optimisticNumbers.map(n => (
          <li key={n}>{n}</li>
        ))}
      </ul>

      <form
        action={async (data: FormData) => {
          const value = Number(data.get('number'));
          await handleAddNumber(value);
        }}
      >
        <input type="number" name="number" />
        <button type="submit" disabled={isPending}>
          Добавить
        </button>
      </form>
    </div>
  );
}

Многошаговый процесс

export function MultiStepAction() {
  const [message, setMessage] = useOptimistic('Submit');
  const [isPending, startTransition] = useTransition();

  const handleClick = () => {
    startTransition(async () => {
      setMessage('Создаём заказ...');
      await new Promise(r => setTimeout(r, 800));
      setMessage('Отправляем данные...');
      await new Promise(r => setTimeout(r, 800));
      setMessage('Подтверждаем заказ...');
      await new Promise(r => setTimeout(r, 800));
      setMessage('Submit'); // вернуться к исходному состоянию
    });
  };

  return (
    <button onClick={handleClick} disabled={isPending}>
      {message}
    </button>
  );
}

Suspense для изображений

Suspense можно использовать не только для данных, но и для загрузки ресурсов, например изображений. Идея:

type LoadedImage = { url: string; width: number; height: number };

function preloadImage(src: string): Promise<LoadedImage> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.src = src;
    img.onload = () =>
      resolve({ url: src, width: img.width, height: img.height });
    img.onerror = () => reject(new Error('Failed to load image'));
  });
}

function AsyncImage({ src }: { src: string }) {
  const { url } = use(preloadImage(src));
  return <img src={url} alt="" />;
}

export function SuspenseImageExample({ src }: { src: string }) {
  return (
    <ErrorBoundary fallback={<div>Не удалось загрузить изображение</div>}>
      <Suspense fallback={<div>Загрузка изображения...</div>}>
        <AsyncImage src={src} />
      </Suspense>
    </ErrorBoundary>
  );
}

useDeferredValue: откладываем тяжёлый рендер

useDeferredValue берёт какое‑то значение и возвращает “отложенную” версию: React может обновлять её чуть позже, когда у браузера будет время. Это особенно полезно при фильтрации/поиске по длинному списку.

const WORDS = 'Хук useDeferredValue в React — это инструмент оптимизации производительности'
  .toLowerCase()
  .split(' ');

export function DeferredSearch() {
  const [search, setSearch] = useState('');
  const deferredSearch = useDeferredValue(search);

  const isPending = search !== deferredSearch;

  const filtered = useMemo(
    () => WORDS.filter(word => word.includes(deferredSearch)),
    [deferredSearch],
  );

  return (
    <div>
      <input
        value={search}
        onChange={e => setSearch(e.target.value)}
        placeholder="Введите часть слова..."
      />

      <div style=>
        {filtered.map((word, index) => (
          <div key={word + index}>{word}</div>
        ))}
      </div>
    </div>
  );
}

Пользователь видит мгновенный отклик ввода, а тяжёлая фильтрация может немного “отставать”, не блокируя интерфейс.

Тяжёлые списки и deferred‑поиск

В реальном приложении вместо небольшого массива может быть гигантский список. Тогда для оптимизации используют:

const BIG_LIST = Array.from({ length: 50_000 }, (_, i) => `Элемент ${i + 1}`);

function HeavyList({ query }: { query: string }) {
  const filtered = useMemo(() => {
    const start = performance.now();
    // имитируем тяжёлую фильтрацию
    while (performance.now() - start < 10) {}

    return BIG_LIST.filter(item =>
      item.toLowerCase().includes(query.toLowerCase()),
    );
  }, [query]);

  return (
    <ul>
      {filtered.map(item => (
        <li key={item}>{item}</li>
      ))}
    </ul>
  );
}

export function DeferredHeavyList() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Поиск..."
      />
      <HeavyList query={deferredQuery} />
    </div>
  );
}

use с несколькими промисами

Хук use можно применять и к нескольким независимым промисам, которые загружаются параллельно.

function waitNumber(n: number, delay: number) {
  return new Promise<number>(resolve => {
    setTimeout(() => resolve(n), delay);
  });
}

export function ParallelNumbers() {
  const a = use(waitNumber(1, 1000));
  const b = use(waitNumber(2, 1200));
  const c = use(waitNumber(3, 800));

  return (
    <div>
      Числа: {[a, b, c].join(', ')}
    </div>
  );
}

В сочетании с Suspense это позволяет строить интерфейсы, которые постепенно “дорисовываются” по мере готовности данных, не блокируя весь экран.