Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Асинхронный интерфейс и Suspense
async_suspense

Асинхронный интерфейс и Suspense

Загрузка данных вне эффектов, Suspense, use, переходы, useDeferredValue, потоковая передача и состояния ожидания.

Открыть лабораториюv0.2.0Запускается локально из публичного репозитория

Асинхронный интерфейс и Suspense

Асинхронность — это не индикатор загрузки, а согласованная модель приоритета, пустых данных, ошибок, повторов и устаревшего содержимого.

#Результат урока

Вы выберете границы загрузки данных, построите интерфейс для ожидания и ошибок через Suspense и сохраните отзывчивость при несрочных обновлениях.

Понадобятся Promise, действия, эффекты, модели состояния и конкурентная отрисовка. Итог урока — поиск по каталогу с загрузкой на уровне маршрута, Suspense, признаком устаревших результатов, переходом и повтором запроса.

#1. Загрузка до отрисовки вместо цепочки эффектов

fetch в эффекте начинается после первой фиксации DOM и часто создаёт цепочку «родитель → запрос → потомок → запрос». Такую цепочку называют каскадом запросов (waterfall): каждый уровень дерева ждёт, пока отрисуется предыдущий. Загрузчик маршрута (route loader) или API фреймворка запускает работу до отрисовки, умеет отменять навигацию и координирует ошибки. Эффект остаётся средством внешней синхронизации, а не универсальной загрузкой данных.

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

// src/routes/order/OrderPage.tsx — каскад: два последовательных round-trip function OrderPage({ id }: { id: string }) { const [order, setOrder] = useState<Order | null>(null); useEffect(() => { let active = true; void fetchOrder(id).then((value) => { if (active) setOrder(value); }); return () => { active = false; }; }, [id]); if (!order) return <OrderSkeleton />; // запрос позиций начнётся только сейчас — после второй фиксации DOM return <OrderItems orderId={order.id} />; }

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

// src/routes/order/route.ts — загрузчик стартует до отрисовки export async function loader({ params, request }: LoaderFunctionArgs) { const id = params.id!; const [order, items] = await Promise.all([ fetchOrder(id, { signal: request.signal }), fetchOrderItems(id, { signal: request.signal }), ]); return { order, items }; }

Разница не в числе строк, а в том, что общее время страницы стало равно самому медленному запросу вместо суммы двух. Дополнительно request.signal отменяет оба запроса, если пользователь ушёл с маршрута до ответа.

Проверьте себя. Сравните последовательность сетевых запросов страницы с эффектами и параллельными загрузчиками маршрутов.

Частая ошибка. Каждый компонент загружает обязательные данные только после монтирования.

#2. Граница Suspense как часть сценария

Когда потомок приостанавливает отрисовку, ближайший Suspense показывает запасное содержимое (fallback). Границу выбирают по осмысленному состоянию загрузки и порядку появления данных, а не оборачивают каждый текст. При обычном обновлении уже показанное содержимое может снова скрыться; маршрутизатор или фреймворк обычно координирует это через переход (transition).

Граница определяет именно ту область, которая исчезнет и будет заменена запасным содержимым. Поэтому заголовок и результаты поиска стоит оставить вне границы, а медленную вторичную панель — внутри:

// src/routes/search/SearchPage.tsx <main> <h1>Каталог</h1> <SearchResults query={query} /> {/* рекомендации медленные и не критичны: их ожидание не должно скрывать список */} <Suspense fallback={<RecommendationsSkeleton />}> <Recommendations query={query} /> </Suspense> </main>

Запасное содержимое должно сохранять разметку: если RecommendationsSkeleton занимает другую высоту, чем готовая панель, страница дёрнется при подстановке данных и испортит метрику сдвига разметки.

Проверьте себя. Разместите границу вокруг независимой панели, сохранив общий заголовок видимым.

Частая ошибка. Полноэкранный индикатор на всё приложение при любой мелкой загрузке.

#3. use и стабильный Promise

use читает Promise или Context и может вызываться условно, в отличие от хуков состояния, но только внутри компонента или хука. Ожидающий Promise приостанавливает отрисовку, выполненный возвращает значение, отклонённый передаёт ошибку ближайшей границе. Создавайте Promise в загрузчике, на сервере или в кеше, а не заново при каждой клиентской отрисовке.

Разница видна на двух строках. Первая создаёт новый запрос при каждой отрисовке и потому приостанавливается бесконечно — каждый новый Promise снова находится в состоянии ожидания:

// ❌ src/features/search/Results.tsx function Results({ query }: { query: string }) { const data = use(fetchResults(query)); // новый Promise на каждую отрисовку return <ResultList items={data.items} />; }

Вторая получает уже созданный Promise сверху: его создал загрузчик один раз на навигацию, поэтому повторная отрисовка читает тот же результат:

// ✅ src/features/search/Results.tsx function Results({ resultsPromise }: { resultsPromise: Promise<ResultsPayload> }) { const data = use(resultsPromise); // тот же Promise между отрисовками return <ResultList items={data.items} />; }

Ключевое требование — идентичность Promise между отрисовками, а не место вызова use. Стабильность даёт загрузчик, серверный компонент или кеш запросов; локальная переменная внутри тела компонента её не даёт.

Проверьте себя. Передайте Promise из загрузчика в клиентский компонент и прочитайте через use.

Частая ошибка. use(fetch(url)) создаёт новый запрос при каждой повторной отрисовке.

#4. Переходы для несрочного обновления

startTransition отмечает обновления состояния как несрочные и допускающие прерывание. Асинхронная функция внутри перехода поддерживается, но обновления после await в некоторых сценариях требуют нового startTransition. Значение управляемого поля нельзя обновлять в переходе: ввод должен отображаться сразу.

В поиске это разделение видно буквально построчно — строка запроса срочная, навигация и результаты несрочные:

// src/features/search/SearchField.tsx const [query, setQuery] = useState(''); const [isPending, startTransition] = useTransition(); function handleChange(event: React.ChangeEvent<HTMLInputElement>) { const next = event.target.value; setQuery(next); // срочно: пользователь должен видеть введённый символ startTransition(() => { // несрочно и прерываемо: следующий ввод отменит эту работу navigate(`/search?q=${encodeURIComponent(next)}`); }); }

Если setQuery поместить внутрь перехода, поле начнёт «отставать» от клавиатуры: React вправе отложить или прервать несрочное обновление, и символ появится не сразу.

Проверьте себя. Оставьте строку запроса срочной, а навигацию и результаты обновите в переходе.

Частая ошибка. Поместить обновление значения поля в переход.

#5. useDeferredValue и устаревшее содержимое

Отложенное значение (deferred value) отстаёт от срочного источника и позволяет тяжёлому поддереву обновиться позже. Это не фиксированная задержка и само по себе не уменьшает число сетевых запросов. Пользователь должен видеть, что список устарел (stale): например, через прозрачность или статус.

Признак устаревания вычисляется сравнением срочного и отложенного значения, и его обязательно нужно сообщить не только визуально:

// src/features/search/SearchResults.tsx const deferredQuery = useDeferredValue(query); const isStale = query !== deferredQuery; return ( <div style={{ opacity: isStale ? 0.6 : 1 }} aria-busy={isStale}> <p role="status" className="sr-only"> {isStale ? 'Обновляем результаты' : `Показаны результаты по запросу «${deferredQuery}»`} </p> <ResultList query={deferredQuery} /> </div> );

Прозрачность — сигнал только для зрячего пользователя. role="status" и aria-busy передают то же состояние программе чтения с экрана, поэтому оба канала нужны вместе.

Проверьте себя. Измените прозрачность при query !== deferredQuery и добавьте доступное текстовое состояние.

Частая ошибка. Показывать старые результаты как будто они соответствуют новому запросу.

#6. Граница ошибок и повтор

Сбой асинхронного ресурса требует ближайшей границы ошибок (Error Boundary) с безопасным сообщением и повтором. Такая граница автоматически не ловит ошибки обработчиков событий и произвольных асинхронных функций. Повтор должен обновить ресурс или ключ навигации, иначе тот же отклонённый результат из кеша немедленно упадёт снова.

Разница между «спрятать сообщение» и «повторить попытку» решается ключом: смена key размонтирует поддерево и заставит создать новый ресурс:

// src/features/search/SearchPanel.tsx const [attempt, setAttempt] = useState(0); return ( <ErrorBoundary key={attempt} fallback={<RetryPanel onRetry={() => setAttempt((value) => value + 1)} />} > <Suspense fallback={<ResultsSkeleton />}> <Results query={query} attempt={attempt} /> </Suspense> </ErrorBoundary> );

attempt участвует в ключе кеша запроса, поэтому новая попытка идёт в сеть, а не читает прежний отклонённый Promise. Без этого кнопка повтора создаёт впечатление работы, но мгновенно возвращает ту же ошибку.

Проверьте себя. Сделайте повтор, который создаёт новую попытку ресурса, а не только скрывает сообщение об ошибке.

Частая ошибка. Кнопка повтора лишь вызывает setError(null), не меняя отклонённый ресурс.

#7. Потоковая передача и порядок появления

Серверная потоковая передача (streaming) отправляет каркас страницы и последующие части по готовности границ Suspense. Это ускоряет появление полезного HTML, но не исправляет медленный сервер и требует стабильной разметки. Критичный заголовок не стоит задерживать из-за рекомендаций внизу страницы.

Порядок появления задаётся тем, что вы поставили внутрь границ, а что оставили в каркасе:

// app/product/[id]/page.tsx export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const product = await getProduct(id); // критично: без этого страница бессмысленна return ( <article> <ProductHeader product={product} /> <Suspense fallback={<ReviewsSkeleton />}> <Reviews productId={id} /> {/* дострим позже, заголовок уже виден */} </Suspense> </article> ); }

Если убрать Suspense вокруг Reviews, весь HTML страницы будет ждать самый медленный запрос: пользователь увидит пустой экран вместо готового заголовка и цены.

Проверьте себя. Выделите каркас, критичное содержимое и позднюю вторичную границу.

Частая ошибка. Поместить заголовок и весь основной контент за медленной вторичной рекомендацией.

#8. Полная матрица асинхронного интерфейса

Как минимум различайте первую загрузку, пустой успех, данные, фоновое обновление, ошибку ввода, восстанавливаемый системный сбой, отсутствие доступа и записи. Одного индикатора недостаточно. Для каждого состояния задайте действие пользователя и доступное текстовое сообщение; не очищайте полезные данные при фоновом обновлении без причины.

Самая частая потеря — пустой успешный ответ, который выглядит как вечная загрузка. Тип с размеченным объединением не позволяет забыть исход:

// src/features/search/state.ts type ResultsState = | { kind: 'loading' } | { kind: 'empty'; query: string } | { kind: 'ready'; items: Result[]; isRefreshing: boolean } | { kind: 'invalid'; message: string } | { kind: 'failed'; retry: () => void };

Проверка data ? list : spinner неотличима для пустого массива и незавершённого запроса, а kind делает эти случаи разными ветвями: компилятор потребует обработать каждую, и тест сможет проверить каждый исход отдельно.

Проверьте себя. Составьте таблицу состояний и тест для каждого исхода.

Частая ошибка. data ? list : spinner бесконечно показывает индикатор для пустого массива.

#Лаборатория

Реализуйте страницу поиска: загрузчик запускает запрос до отрисовки, результаты читаются через use, запасное содержимое сохраняет разметку, смена фильтра идёт через переход, прежний список помечается устаревшим, ошибки допускают повтор.

Работа готова, когда нет последовательной загрузки из эффектов, срочный ввод не блокируется, а пустое, ошибочное, ожидающее и устаревшее состояния различимы и протестированы.

#Источники для сверки

  • React Suspense
  • React use
  • React useTransition
  • React useDeferredValue

Устранение неисправностей

Далее: Маршрутизация и загрузка данных