Перейти к содержанию

Suspense

<Suspense> позволяет отображать фалбэк до тех пор, пока его дочерние элементы не закончат загрузку.

1
2
3
<Suspense fallback={<Loading />}>
    <SomeComponent />
</Suspense>

Описание

<Suspense>

Свойства

  • children: Фактический пользовательский интерфейс, который вы собираетесь рендерить. Если children приостановится во время рендеринга, граница Suspense переключится на рендеринг fallback.
  • fallback: Альтернативный пользовательский интерфейс, который будет отображаться вместо реального пользовательского интерфейса, если он не закончил загрузку. Принимается любой допустимый узел React, хотя на практике запасной вариант - это легковесное представление-заполнитель, например, загрузочный спиннер или скелет. Приостановка будет автоматически переключаться на fallback, когда children приостанавливает работу, и обратно на children, когда данные будут готовы. Если fallback приостанавливает работу во время рендеринга, он активирует ближайшую родительскую границу Suspense.

Ограничения

  • React не сохраняет состояние для рендеров, которые были приостановлены до того, как они смогли смонтироваться в первый раз. Когда компонент загрузится, React повторит попытку рендеринга приостановленного дерева с нуля.
  • Если Suspense отображал содержимое для дерева, но затем снова приостановился, то откат будет показан снова, если только обновление, вызвавшее его, не было вызвано startTransition или useDeferredValue.
  • Если React необходимо скрыть уже видимый контент из-за повторного приостановления, он очистит layout Effects в дереве контента. Когда контент снова будет готов к показу, React снова запустит Эффекты компоновки. Это гарантирует, что Эффекты, измеряющие макет DOM, не попытаются сделать это, пока содержимое скрыто.
  • React включает в себя такие "подкапотные" оптимизации, как Streaming Server Rendering и Selective Hydration, которые интегрированы в Suspense. Чтобы узнать больше, прочитайте архитектурный обзор и посмотрите технический доклад.

Что активирует границу Suspense

Граница Suspense ждёт, пока содержимое будет готово, и только потом показывает его. Граница не раскрывает содержимое, пока происходит что-то из этого списка:

  • Ленивая загрузка кода компонента через lazy.
  • Чтение промиса через use, включая данные, которые приходят потоком из серверных компонентов или загружаются через фреймворк с поддержкой Suspense.
  • Загрузка таблицы стилей, отрисованной через <link rel="stylesheet"> с пропом precedence. React удерживает границу, пока таблица стилей не загрузится, но не дольше тайм-аута. Пример ниже.
  • Ожидание HTML большой границы при потоковом серверном рендеринге. Передача HTML занимает время, поэтому граница с достаточным объёмом содержимого активируется, даже если внутри ничего не приостанавливается. React показывает содержимое по мере поступления HTML.
  • Загрузка шрифтов. По умолчанию Suspense не ждёт шрифты, но обновление <ViewTransition> ждёт загрузки новых шрифтов до тайм-аута, чтобы текст не мигал запасным шрифтом. Пример ниже.
  • Загрузка изображений. По умолчанию Suspense не ждёт изображения, но во время обновления <ViewTransition> React удерживает границу, пока изображение не загрузится, до тайм-аута. Обработчик onLoad исключает конкретное изображение из этого ожидания. Пример ниже.
  • Вычисления, которые занимают процессор, внутри границы <Suspense defer>.

Фреймворки с поддержкой Suspense

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

Без фреймворка промис можно читать через use напрямую, если промис закэширован и один и тот же экземпляр переиспользуется между рендерами.

Использование

Отображение фолбэка во время загрузки контента

Вы можете обернуть любую часть вашего приложения границей Suspense:

1
2
3
<Suspense fallback={<Loading />}>
    <Albums />
</Suspense>

React будет отображать ваш loading fallback до тех пор, пока весь код и данные, необходимые потомкам не будут загружены.

В приведенном ниже примере компонент Albums приостанавливается на время получения списка альбомов. Пока он не готов к рендерингу, React переключает ближайшую границу Suspense выше, чтобы показать отступающий компонент - ваш компонент Loading. Затем, когда данные загружаются, React скрывает компонент Loading и отображает компонент Albums с данными.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { Suspense } from 'react';
import Albums from './Albums.js';

export default function ArtistPage({ artist }) {
    return (
        <>
            <h1>{artist.name}</h1>
            <Suspense fallback={<Loading />}>
                <Albums artistId={artist.id} />
            </Suspense>
        </>
    );
}

function Loading() {
    return <h2>🌀 Loading...</h2>;
}

Поддержка Suspense

Только источники данных с поддержкой Suspense активируют компонент Suspense. К ним относятся:

  • Получение данных с помощью фреймворков с поддержкой Suspense, таких как Relay и Next.js.
  • Ленивая загрузка кода компонента с помощью lazy.
  • Считывание значения промиса с использованием use

Suspense не обнаруживает, когда данные извлекаются внутри Effect или обработчика события.

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

Получение данных с поддержкой Suspense без использования мнений фреймворка пока не поддерживается. Требования к реализации источника данных с поддержкой Suspense нестабильны и не документированы. Официальный API для интеграции источников данных с Suspense будет выпущен в одной из будущих версий React.

Раскрытие содержимого сразу

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

1
2
3
4
5
6
<Suspense fallback={<Loading />}>
    <Biography />
    <Panel>
        <Albums />
    </Panel>
</Suspense>

Затем, когда все они будут готовы к отображению, они появятся все вместе одновременно.

В приведенном ниже примере и Biography, и Albums получают некоторые данные. Однако, поскольку они сгруппированы под одной границей Suspense, эти компоненты всегда "всплывают" вместе в одно и то же время.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Suspense } from 'react';
import Albums from './Albums.js';
import Biography from './Biography.js';
import Panel from './Panel.js';

export default function ArtistPage({ artist }) {
    return (
        <>
            <h1>{artist.name}</h1>
            <Suspense fallback={<Loading />}>
                <Biography artistId={artist.id} />
                <Panel>
                    <Albums artistId={artist.id} />
                </Panel>
            </Suspense>
        </>
    );
}

function Loading() {
    return <h2>🌀 Loading...</h2>;
}
1
2
3
export default function Panel({ children }) {
    return <section className="panel">{children}</section>;
}

Компоненты, загружающие данные, не обязательно должны быть прямыми дочерними компонентами границы Suspense. Например, вы можете переместить Biography и Albums в новый компонент Details. Это не изменит поведение. Biography и Albums имеют одну и ту же ближайшую родительскую границу Suspense, поэтому их раскрытие координируется вместе.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<Suspense fallback={<Loading />}>
    <Details artistId={artist.id} />
</Suspense>;

function Details({ artistId }) {
    return (
        <>
            <Biography artistId={artistId} />
            <Panel>
                <Albums artistId={artistId} />
            </Panel>
        </>
    );
}

Раскрытие вложенного содержимого по мере загрузки

Когда компонент приостанавливается, ближайший родительский Suspense-компонент показывает запасной вариант. Это позволяет вложить несколько компонентов Suspense для создания последовательности загрузки. Падение каждой границы Suspense будет заполняться по мере того, как становится доступным содержимое следующего уровня. Например, вы можете дать списку альбомов свой собственный откат:

1
2
3
4
5
6
7
8
<Suspense fallback={<BigSpinner />}>
    <Biography />
    <Suspense fallback={<AlbumsGlimmer />}>
        <Panel>
            <Albums />
        </Panel>
    </Suspense>
</Suspense>

С этим изменением отображение Biography не должно "ждать" загрузки Albums.

Последовательность будет следующей:

  1. Если Biography еще не загрузилась, BigSpinner отображается вместо всей области содержимого.
  2. Как только Biography завершает загрузку, BigSpinner заменяется содержимым.
  3. Если Albums еще не загрузились, AlbumsGlimmer отображается вместо Albums и его родительской Panel.
  4. Наконец, когда Albums завершает загрузку, он заменяет AlbumsGlimmer.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { Suspense } from 'react';
import Albums from './Albums.js';
import Biography from './Biography.js';
import Panel from './Panel.js';

export default function ArtistPage({ artist }) {
    return (
        <>
            <h1>{artist.name}</h1>
            <Suspense fallback={<BigSpinner />}>
                <Biography artistId={artist.id} />
                <Suspense fallback={<AlbumsGlimmer />}>
                    <Panel>
                        <Albums artistId={artist.id} />
                    </Panel>
                </Suspense>
            </Suspense>
        </>
    );
}

function BigSpinner() {
    return <h2>🌀 Loading...</h2>;
}

function AlbumsGlimmer() {
    return (
        <div className="glimmer-panel">
            <div className="glimmer-line" />
            <div className="glimmer-line" />
            <div className="glimmer-line" />
        </div>
    );
}
1
2
3
export default function Panel({ children }) {
    return <section className="panel">{children}</section>;
}

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

Не ставьте приостанавливающую границу вокруг каждого компонента. Границы приостановки не должны быть более детализированными, чем последовательность загрузки, которую вы хотите, чтобы испытал пользователь. Если вы работаете с дизайнером, спросите его, где должны располагаться состояния загрузки - скорее всего, они уже включили их в свои эскизы.

Показ устаревшего контента во время загрузки свежего

В этом примере компонент SearchResults приостанавливается на время получения результатов поиска. Введите "a", дождитесь результатов, а затем измените его на "ab". Результаты для "a" будут заменены загрузочным фалбэком.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Suspense, useState } from 'react';
import SearchResults from './SearchResults.js';

export default function App() {
    const [query, setQuery] = useState('');
    return (
        <>
            <label>
                Search albums:
                <input
                    value={query}
                    onChange={(e) =>
                        setQuery(e.target.value)
                    }
                />
            </label>
            <Suspense fallback={<h2>Loading...</h2>}>
                <SearchResults query={query} />
            </Suspense>
        </>
    );
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
export default function App() {
    const [query, setQuery] = useState('');
    const deferredQuery = useDeferredValue(query);
    return (
        <>
            <label>
                Search albums:
                <input
                    value={query}
                    onChange={(e) =>
                        setQuery(e.target.value)
                    }
                />
            </label>
            <Suspense fallback={<h2>Loading...</h2>}>
                <SearchResults query={deferredQuery} />
            </Suspense>
        </>
    );
}

Запрос query будет обновлен немедленно, поэтому на входе будет отображаться новое значение. Однако deferredQuery сохранит свое предыдущее значение до тех пор, пока данные не загрузятся, поэтому SearchResults будет отображать устаревшие результаты некоторое время.

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

1
2
3
4
5
6
7
<div
    style={{
        opacity: query !== deferredQuery ? 0.5 : 1,
    }}
>
    <SearchResults query={deferredQuery} />
</div>

Введите "a" в примере ниже, дождитесь загрузки результатов, а затем измените ввод на "ab". Обратите внимание, что вместо отката на приостановку вы теперь видите затемненный список несвежих результатов, пока не загрузятся новые результаты:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import {
    Suspense,
    useState,
    useDeferredValue,
} from 'react';
import SearchResults from './SearchResults.js';

export default function App() {
    const [query, setQuery] = useState('');
    const deferredQuery = useDeferredValue(query);
    const isStale = query !== deferredQuery;
    return (
        <>
            <label>
                Search albums:
                <input
                    value={query}
                    onChange={(e) =>
                        setQuery(e.target.value)
                    }
                />
            </label>
            <Suspense fallback={<h2>Loading...</h2>}>
                <div style={{ opacity: isStale ? 0.5 : 1 }}>
                    <SearchResults query={deferredQuery} />
                </div>
            </Suspense>
        </>
    );
}

И отложенные значения, и transitions позволяют вам избежать отображения Suspense fallback в пользу встроенных индикаторов. Переходы помечают все обновление как несрочное, поэтому они обычно используются фреймворками и библиотеками маршрутизаторов для навигации. Отложенные значения, с другой стороны, в основном полезны в коде приложений, где вы хотите пометить часть пользовательского интерфейса как несрочную и позволить ей "отстать" от остальной части пользовательского интерфейса.

Предотвращение скрытия уже раскрытого содержимого

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { Suspense, useState } from 'react';
import IndexPage from './IndexPage.js';
import ArtistPage from './ArtistPage.js';
import Layout from './Layout.js';

export default function App() {
    return (
        <Suspense fallback={<BigSpinner />}>
            <Router />
        </Suspense>
    );
}

function Router() {
    const [page, setPage] = useState('/');

    function navigate(url) {
        setPage(url);
    }

    let content;
    if (page === '/') {
        content = <IndexPage navigate={navigate} />;
    } else if (page === '/the-beatles') {
        content = (
            <ArtistPage
                artist={{
                    id: 'the-beatles',
                    name: 'The Beatles',
                }}
            />
        );
    }
    return <Layout>{content}</Layout>;
}

function BigSpinner() {
    return <h2>🌀 Loading...</h2>;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
export default function Layout({ children }) {
    return (
        <div className="layout">
            <section className="header">
                Music Browser
            </section>
            <main>{children}</main>
        </div>
    );
}
1
2
3
4
5
6
7
export default function IndexPage({ navigate }) {
    return (
        <button onClick={() => navigate('/the-beatles')}>
            Open The Beatles artist page
        </button>
    );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { Suspense } from 'react';
import Albums from './Albums.js';
import Biography from './Biography.js';
import Panel from './Panel.js';

export default function ArtistPage({ artist }) {
    return (
        <>
            <h1>{artist.name}</h1>
            <Biography artistId={artist.id} />
            <Suspense fallback={<AlbumsGlimmer />}>
                <Panel>
                    <Albums artistId={artist.id} />
                </Panel>
            </Suspense>
        </>
    );
}

function AlbumsGlimmer() {
    return (
        <div className="glimmer-panel">
            <div className="glimmer-line" />
            <div className="glimmer-line" />
            <div className="glimmer-line" />
        </div>
    );
}

При нажатии кнопки компонент Router отображал ArtistPage вместо IndexPage. Компонент внутри ArtistPage приостанавливался, поэтому ближайшая граница Suspense начинала показывать откат. Ближайшая Suspense-граница находилась рядом с корнем, поэтому весь макет сайта заменялся на BigSpinner.

Чтобы предотвратить это, вы можете пометить обновление состояния навигации как переход с помощью startTransition:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function Router() {
    const [page, setPage] = useState('/');

    function navigate(url) {
        startTransition(() => {
            setPage(url);
        });
    }
    // ...
}

Это говорит React, что переход состояния не является срочным, и лучше продолжать показывать предыдущую страницу вместо того, чтобы скрывать уже открытое содержимое. Теперь нажатие на кнопку "ждет" загрузки Биографии:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { Suspense, startTransition, useState } from 'react';
import IndexPage from './IndexPage.js';
import ArtistPage from './ArtistPage.js';
import Layout from './Layout.js';

export default function App() {
    return (
        <Suspense fallback={<BigSpinner />}>
            <Router />
        </Suspense>
    );
}

function Router() {
    const [page, setPage] = useState('/');

    function navigate(url) {
        startTransition(() => {
            setPage(url);
        });
    }

    let content;
    if (page === '/') {
        content = <IndexPage navigate={navigate} />;
    } else if (page === '/the-beatles') {
        content = (
            <ArtistPage
                artist={{
                    id: 'the-beatles',
                    name: 'The Beatles',
                }}
            />
        );
    }
    return <Layout>{content}</Layout>;
}

function BigSpinner() {
    return <h2>🌀 Loading...</h2>;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
export default function Layout({ children }) {
    return (
        <div className="layout">
            <section className="header">
                Music Browser
            </section>
            <main>{children}</main>
        </div>
    );
}
1
2
3
4
5
6
7
export default function IndexPage({ navigate }) {
    return (
        <button onClick={() => navigate('/the-beatles')}>
            Open The Beatles artist page
        </button>
    );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { Suspense } from 'react';
import Albums from './Albums.js';
import Biography from './Biography.js';
import Panel from './Panel.js';

export default function ArtistPage({ artist }) {
    return (
        <>
            <h1>{artist.name}</h1>
            <Biography artistId={artist.id} />
            <Suspense fallback={<AlbumsGlimmer />}>
                <Panel>
                    <Albums artistId={artist.id} />
                </Panel>
            </Suspense>
        </>
    );
}

function AlbumsGlimmer() {
    return (
        <div className="glimmer-panel">
            <div className="glimmer-line" />
            <div className="glimmer-line" />
            <div className="glimmer-line" />
        </div>
    );
}

Переход не ждет, пока загрузится все содержимое. Он ждет только достаточно долго, чтобы не скрыть уже открытое содержимое. Например, сайт Layout уже был показан, поэтому было бы плохо скрывать его за загружающимся волчком. Однако вложенная граница Suspense вокруг Albums является новой, поэтому переход не ждет ее.

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

Индикация того, что переход происходит

В приведенном выше примере после нажатия на кнопку нет визуальной индикации того, что происходит переход. Чтобы добавить индикатор, вы можете заменить startTransition на useTransition, что даст вам булево значение isPending. В примере ниже это используется для изменения стиля заголовка сайта во время перехода:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { Suspense, useState, useTransition } from 'react';
import IndexPage from './IndexPage.js';
import ArtistPage from './ArtistPage.js';
import Layout from './Layout.js';

export default function App() {
    return (
        <Suspense fallback={<BigSpinner />}>
            <Router />
        </Suspense>
    );
}

function Router() {
    const [page, setPage] = useState('/');
    const [isPending, startTransition] = useTransition();

    function navigate(url) {
        startTransition(() => {
            setPage(url);
        });
    }

    let content;
    if (page === '/') {
        content = <IndexPage navigate={navigate} />;
    } else if (page === '/the-beatles') {
        content = (
            <ArtistPage
                artist={{
                    id: 'the-beatles',
                    name: 'The Beatles',
                }}
            />
        );
    }
    return <Layout isPending={isPending}>{content}</Layout>;
}

function BigSpinner() {
    return <h2>🌀 Loading...</h2>;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
export default function Layout({ children, isPending }) {
    return (
        <div className="layout">
            <section
                className="header"
                style={{
                    opacity: isPending ? 0.7 : 1,
                }}
            >
                Music Browser
            </section>
            <main>{children}</main>
        </div>
    );
}
1
2
3
4
5
6
7
export default function IndexPage({ navigate }) {
    return (
        <button onClick={() => navigate('/the-beatles')}>
            Open The Beatles artist page
        </button>
    );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { Suspense } from 'react';
import Albums from './Albums.js';
import Biography from './Biography.js';
import Panel from './Panel.js';

export default function ArtistPage({ artist }) {
    return (
        <>
            <h1>{artist.name}</h1>
            <Biography artistId={artist.id} />
            <Suspense fallback={<AlbumsGlimmer />}>
                <Panel>
                    <Albums artistId={artist.id} />
                </Panel>
            </Suspense>
        </>
    );
}

function AlbumsGlimmer() {
    return (
        <div className="glimmer-panel">
            <div className="glimmer-line" />
            <div className="glimmer-line" />
            <div className="glimmer-line" />
        </div>
    );
}

Сброс границ приостановки при навигации

Во время перехода React будет избегать скрытия уже показанного содержимого. Однако, если вы переходите на маршрут с другими параметрами, вы можете захотеть сказать React, что это другой контент. Вы можете выразить это с помощью key:

1
<ProfilePage key={queryParams.id} />

Представьте, что вы перемещаетесь по странице профиля пользователя, и что-то приостанавливается. Если это обновление завернуто в переход, оно не вызовет откат для уже видимого содержимого. Это ожидаемое поведение.

Однако теперь представьте, что вы перемещаетесь между двумя разными профилями пользователей. В этом случае имеет смысл показать откат. Например, временная шкала одного пользователя представляет собой различное содержимое, чем временная шкала другого пользователя. Указывая ключ, вы гарантируете, что React рассматривает профили разных пользователей как разные компоненты и сбрасывает границы Suspense во время навигации. Маршрутизаторы, интегрированные в Suspense, должны делать это автоматически.

Предоставление обратного хода для ошибок сервера и контента только для сервера

Если вы используете один из API потокового серверного рендеринга (или фреймворк, который полагается на них), React также будет использовать ваши границы <Suspense> для обработки ошибок на сервере. Если компонент выдает ошибку на сервере, React не будет прерывать серверный рендеринг. Вместо этого он найдет ближайший компонент <Suspense> над ним и включит его фалбэк (например, спиннер) в сгенерированный серверный HTML. Пользователь сначала увидит спиннер.

На клиенте React попытается отрисовать тот же компонент еще раз. Если и на клиенте произойдет ошибка, React выдаст ошибку и отобразит ближайшую границу ошибки. Однако, если ошибка не произойдет на клиенте, React не будет отображать ошибку пользователю, так как содержимое в итоге было отображено успешно.

Вы можете использовать это, чтобы исключить некоторые компоненты из рендеринга на сервере. Для этого бросьте ошибку в серверное окружение, а затем оберните их в границу <Suspense>, чтобы заменить их HTML фалбэками:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<Suspense fallback={<Loading />}>
    <Chat />
</Suspense>;

function Chat() {
    if (typeof window === 'undefined') {
        throw Error(
            'Chat should only render on the client.'
        );
    }
    // ...
}

HTML сервера будет включать индикатор загрузки. На клиенте он будет заменен компонентом Chat.

Фолбэк для содержимого только в браузере

Граница приостановки может показать фолбэк для компонента, который нужен только в браузере. Оберните компонент в <Suspense> и вызовите внутри use(browser()).

Нажмите Reload, чтобы увидеть фолбэк загрузки в исходном HTML. После гидратации React показывает черновик, загруженный из localStorage.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { Suspense, use, useState } from 'react';
import { browser } from 'react-dom';

function SavedDraft() {
    use(browser('The draft is stored in localStorage.'));
    const [draft, setDraft] = useState(
        () => localStorage.getItem('draft') ?? ''
    );

    function handleChange(event) {
        const nextDraft = event.target.value;
        setDraft(nextDraft);
        localStorage.setItem('draft', nextDraft);
    }

    return (
        <label>
            Draft:
            <textarea
                value={draft}
                onChange={handleChange}
                rows={4}
                cols={30}
            />
        </label>
    );
}

export default function App() {
    return (
        <>
            <h1>Saved draft</h1>
            <Suspense fallback={<p>Loading draft...</p>}>
                <SavedDraft />
            </Suspense>
        </>
    );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import App from './App.js';

export default function Document() {
    return (
        <html lang="en">
            <head>
                <title>Saved draft</title>
                <style>{`
                    h1 { font-size: 24px; margin-top: 0; }
                    label, textarea { display: block; }
                    textarea { margin-top: 5px; }
                `}</style>
            </head>
            <body>
                <App />
            </body>
        </html>
    );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
export async function flushReadableStreamToFrame(readable, frame) {
    const doc = frame.contentWindow.document;
    const decoder = new TextDecoder();
    const reader = readable.getReader();

    while (true) {
        const {done, value} = await reader.read();
        if (done) {
            break;
        }
        doc.write(decoder.decode(value, {stream: true}));
    }

    doc.write(decoder.decode());
    doc.close();
}
1
2
3
4
5
iframe {
  width: 100%;
  height: 160px;
  border: 0;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
  "dependencies": {
    "react": "19.3.0-canary-f1f7ed2a-20260904",
    "react-dom": "19.3.0-canary-f1f7ed2a-20260904",
    "react-scripts": "latest"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  }
}

Во время серверного рендеринга React включает фолбэк границы приостановки в HTML. В браузере React заменяет фолбэк сохранённым черновиком.

Ожидание загрузки таблицы стилей

Таблица стилей, отрендеренная через <link rel="stylesheet"> и проп precedence, блокирует границу приостановки, пока таблица стилей не загрузится, но не дольше таймаута, чтобы содержимое не появилось без стилей.

В примере ниже компонент Card рендерит таблицу стилей с precedence. Нажмите «Show card»: React показывает фолбэк, пока таблица стилей не загрузится, а затем раскрывает карточку уже со стилями.

Для сравнения вторая кнопка выполняет то же обновление без React, в отдельном документе. Ничто не ждёт таблицу стилей, поэтому текст карточки сначала появляется запасным шрифтом, а потом переключается:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { Suspense, useState, startTransition } from 'react';
import { freshStylesheetUrl } from './styles.js';
import VanillaCard from './VanillaCard.js';

function Card({ href }) {
    return (
        <>
            <link rel="stylesheet" href={href} precedence="default" />
            <div className="fancy-card">This card uses a font from the stylesheet.</div>
        </>
    );
}

export default function App() {
    const [href, setHref] = useState(null);
    return (
        <>
            <button
                onClick={() => {
                    startTransition(() => {
                        setHref(freshStylesheetUrl());
                    });
                }}>
                Show card
            </button>
            {href && (
                <Suspense fallback={<p>⌛ Loading styles...</p>}>
                    <Card href={href} />
                </Suspense>
            )}
            <hr />
            <VanillaCard />
        </>
    );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { useRef } from 'react';
import { freshStylesheetUrl } from './styles.js';

export default function VanillaCard() {
    const ref = useRef(null);
    function show() {
        const doc = ref.current.contentWindow.document;
        doc.open();
        doc.write(`
            <style>
                body { margin: 0; }
                .fancy-card {
                    padding: 20px;
                    border-radius: 8px;
                    color: white;
                    font-family: 'Caveat', sans-serif;
                    font-size: 24px;
                    background: linear-gradient(135deg, #087ea4, #2b3491);
                }
            </style>
            <div class="fancy-card">This card uses a font from the stylesheet.</div>
            <link rel="stylesheet" href="${freshStylesheetUrl()}">
        `);
        doc.close();
    }
    return (
        <>
            <button onClick={show}>Show card (without React)</button>
            <iframe ref={ref} title="Vanilla card" className="vanilla-frame" />
        </>
    );
}
1
2
3
4
5
6
7
8
9
// Add a unique parameter so the stylesheet isn't cached,
// and every run shows the loading state.
export function freshStylesheetUrl() {
    return (
        'https://fonts.googleapis.com/css2?family=Caveat&display=swap' +
        '&t=' +
        Date.now()
    );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#root {
  min-height: 300px;
}
button {
  margin-right: 8px;
}
hr {
  margin: 16px 0;
}
.fancy-card {
  margin-top: 1em;
  padding: 20px;
  border-radius: 8px;
  color: white;
  font-family: 'Caveat', sans-serif;
  font-size: 24px;
  background: linear-gradient(135deg, #087ea4, #2b3491);
}
.vanilla-frame {
  display: block;
  margin-top: 1em;
  border: none;
  width: 100%;
  height: 90px;
}

Анимация от содержимого Suspense

Suspense сочетается с <ViewTransition>, чтобы анимировать смену фолбэка на содержимое. Оберните границу в <ViewTransition>, и React воспримет эту смену как обновление и по умолчанию сделает перекрёстное затухание между фолбэком и содержимым:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
function Thumbnail({video, children}) {
    return (
        <div
            aria-hidden="true"
            tabIndex={-1}
            className={`thumbnail ${video.image}`}
        />
    );
}

export function Video({video}) {
    return (
        <div className="video">
            <div className="link">
                <Thumbnail video={video}></Thumbnail>
                <div className="info">
                    <div className="video-title">{video.title}</div>
                    <div className="video-description">{video.description}</div>
                </div>
            </div>
        </div>
    );
}

export function VideoPlaceholder() {
    const video = {image: 'loading'};
    return (
        <div className="video">
            <div className="link">
                <Thumbnail video={video}></Thumbnail>
                <div className="info">
                    <div className="video-title loading" />
                    <div className="video-description loading" />
                </div>
            </div>
        </div>
    );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import {ViewTransition, useState, startTransition, Suspense} from 'react';
import {Video, VideoPlaceholder} from './Video';
import {useLazyVideoData} from './data';

function LazyVideo() {
    const video = useLazyVideoData();
    return <Video video={video} />;
}

export default function Component() {
    const [showItem, setShowItem] = useState(false);
    return (
        <>
            <button
                onClick={() => {
                    startTransition(() => {
                        setShowItem((prev) => !prev);
                    });
                }}>
                {showItem ? '➖' : '➕'}
            </button>
            {showItem ? (
                <ViewTransition>
                    <Suspense fallback={<VideoPlaceholder />}>
                        <LazyVideo />
                    </Suspense>
                </ViewTransition>
            ) : null}
        </>
    );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import {use} from 'react';

let cache = null;

function fetchVideo() {
    if (!cache) {
        cache = new Promise((resolve) => {
            setTimeout(() => {
                resolve({
                    id: '1',
                    title: 'First video',
                    description: 'Video description',
                    image: 'blue',
                });
            }, 1000);
        });
    }
    return cache;
}

export function useLazyVideoData() {
    return use(fetchVideo());
}
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
#root {
  display: flex;
  flex-direction: column;
  align-items: center;
  min-height: 200px;
}
button {
  border: none;
  border-radius: 50%;
  width: 50px;
  height: 50px;
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: #f0f8ff;
  color: white;
  font-size: 20px;
  cursor: pointer;
  transition: background-color 0.3s, border 0.3s;
}
button:hover {
  border: 2px solid #ccc;
  background-color: #e0e8ff;
}
.thumbnail {
  position: relative;
  aspect-ratio: 16 / 9;
  display: flex;
  overflow: hidden;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  border-radius: 0.5rem;
  outline-offset: 2px;
  width: 8rem;
  vertical-align: middle;
  background-color: #ffffff;
  background-size: cover;
  user-select: none;
}
.thumbnail.blue {
  background-image: conic-gradient(at top right, #c76a15, #087ea4, #2b3491);
}
.loading {
  background-image: linear-gradient(
    90deg,
    rgba(173, 216, 230, 0.3) 25%,
    rgba(135, 206, 250, 0.5) 50%,
    rgba(173, 216, 230, 0.3) 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
  0% {
    background-position: -200% 0;
  }
  100% {
    background-position: 200% 0;
  }
}
.video {
  display: flex;
  flex-direction: row;
  gap: 0.75rem;
  align-items: center;
  margin-top: 1em;
}
.video .link {
  display: flex;
  flex-direction: row;
  flex: 1 1 0;
  gap: 0.125rem;
  outline-offset: 4px;
  cursor: pointer;
}
.video .info {
  display: flex;
  flex-direction: column;
  justify-content: center;
  margin-left: 8px;
  gap: 0.125rem;
}
.video .info:hover {
  text-decoration: underline;
}
.video-title {
  font-size: 15px;
  line-height: 1.25;
  font-weight: 700;
  color: #23272f;
}
.video-title.loading {
  height: 20px;
  width: 80px;
  border-radius: 0.5rem;
}
.video-description {
  color: #5e687e;
  font-size: 13px;
  border-radius: 0.5rem;
}
.video-description.loading {
  height: 15px;
  width: 100px;
}
1
2
3
4
5
6
7
{
  "dependencies": {
    "react": "19.3.0-canary-f1f7ed2a-20260904",
    "react-dom": "19.3.0-canary-f1f7ed2a-20260904",
    "react-scripts": "latest"
  }
}

Примечание

От того, где <ViewTransition> стоит относительно границы, зависит, будет ли фолбэк и содержимое перекрёстно затухать как одно обновление или анимироваться как отдельные анимации выхода и входа. Анимацию также можно настроить классами View Transition.

Подробнее об анимации от содержимого Suspense.

Ожидание загрузки шрифта

Когда <ViewTransition> анимирует раскрытие границы приостановки, React ждёт новые шрифты, которые вводит содержимое, но не дольше таймаута, чтобы текст не мигал запасным шрифтом. Это происходит только во время обновления <ViewTransition>.

В примере ниже граница приостановки обёрнута в <ViewTransition>, а компонент Quote приостанавливается, пока загружаются его данные. Рендер цитаты запускает загрузку шрифта. React держит фолбэк видимым, пока шрифт не загрузится, поэтому цитата появляется уже своим шрифтом.

Для сравнения вторая кнопка выполняет то же обновление без React. Ничто не ждёт шрифт, поэтому текст сначала появляется запасным шрифтом, а потом переключается:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { ViewTransition, Suspense, use, useState, startTransition } from 'react';
import { fetchQuote } from './data.js';
import { freshFontUrl } from './font.js';
import VanillaQuote from './VanillaQuote.js';

function Quote({ fontSrc }) {
    const quote = use(fetchQuote());
    return (
        <>
            <style href={fontSrc} precedence="default">
                {`@font-face {
                    font-family: 'Fancy';
                    src: url(${fontSrc}) format('truetype');
                    font-display: swap;
                }`}
            </style>
            <p className="quote fancy">{quote}</p>
        </>
    );
}

export default function App() {
    const [fontSrc, setFontSrc] = useState(null);
    return (
        <>
            <button
                onClick={() => {
                    startTransition(() => {
                        setFontSrc(freshFontUrl());
                    });
                }}>
                Show quote
            </button>
            {fontSrc && (
                <ViewTransition>
                    <Suspense fallback={<p className="quote">⌛ Loading quote...</p>}>
                        <Quote fontSrc={fontSrc} />
                    </Suspense>
                </ViewTransition>
            )}
            <hr />
            <VanillaQuote />
        </>
    );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useRef } from 'react';
import { freshFontUrl } from './font.js';

export default function VanillaQuote() {
    const ref = useRef(null);
    function show() {
        const style = document.createElement('style');
        style.textContent = `@font-face {
            font-family: 'VanillaFancy';
            src: url(${freshFontUrl()}) format('truetype');
            font-display: swap;
        }`;
        document.head.appendChild(style);
        ref.current.innerHTML = `<p class="quote vanilla-fancy">The best way to predict the future is to invent it.</p>`;
    }
    return (
        <>
            <button onClick={show}>Show quote (without React)</button>
            <div ref={ref} />
        </>
    );
}
1
2
3
4
5
6
7
8
9
// Add a unique parameter so the font isn't cached,
// and every run shows the loading state.
export function freshFontUrl() {
    return (
        'https://raw.githubusercontent.com/google/fonts/main/ofl/caveat/Caveat%5Bwght%5D.ttf' +
        '?t=' +
        Date.now()
    );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Note: the way you would do data fetching depends on
// the framework that you use together with Suspense.
// Normally, the caching logic would be inside a framework.

let cache = null;

export function fetchQuote() {
    if (!cache) {
        cache = new Promise((resolve) => {
            // Add a fake delay to make waiting noticeable.
            setTimeout(() => {
                resolve(
                    'The best way to predict the future is to invent it.'
                );
            }, 500);
        });
    }
    return cache;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#root {
  min-height: 260px;
}
.quote {
  font-size: 20px;
  margin-top: 1em;
}
.fancy {
  font-family: 'Fancy', sans-serif;
}
.vanilla-fancy {
  font-family: 'VanillaFancy', sans-serif;
}
hr {
  margin: 16px 0;
}
1
2
3
4
5
6
7
{
  "dependencies": {
    "react": "19.3.0-canary-f1f7ed2a-20260904",
    "react-dom": "19.3.0-canary-f1f7ed2a-20260904",
    "react-scripts": "latest"
  }
}

Ожидание загрузки изображения

Когда <ViewTransition> анимирует раскрытие границы приостановки, React ждёт загрузки видимых изображений, но не дольше таймаута, чтобы анимация не началась с наполовину загруженной картинки. Это происходит только во время обновления <ViewTransition>. Обработчик onLoad исключает конкретное изображение из ожидания, даже внутри <ViewTransition>.

В примере ниже граница приостановки обёрнута в <ViewTransition> и показывает скелет профиля, пока не загрузится портрет.

Для сравнения вторая кнопка выполняет то же обновление без React. Ничто не ждёт изображение, поэтому карточка появляется сразу, а картинка всплывает, когда загрузится:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import { ViewTransition, Suspense, useState, startTransition } from 'react';
import { freshImageUrl } from './image.js';
import VanillaProfile from './VanillaProfile.js';

function Profile({ src }) {
    return (
        <div className="card">
            <img src={src} alt="Jack Pope" width={80} height={80} />
            <p>Jack Pope</p>
        </div>
    );
}

function ProfilePlaceholder() {
    return (
        <div className="card">
            <div className="avatar-placeholder" />
            <p className="name-placeholder">&nbsp;</p>
        </div>
    );
}

export default function App() {
    const [src, setSrc] = useState(null);
    return (
        <>
            <button
                onClick={() => {
                    startTransition(() => {
                        setSrc(freshImageUrl());
                    });
                }}>
                Show profile
            </button>
            {src && (
                <ViewTransition>
                    <Suspense fallback={<ProfilePlaceholder />}>
                        <Profile src={src} />
                    </Suspense>
                </ViewTransition>
            )}
            <hr />
            <VanillaProfile />
        </>
    );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import { useRef } from 'react';
import { freshImageUrl } from './image.js';

export default function VanillaProfile() {
    const ref = useRef(null);
    function show() {
        ref.current.innerHTML = `<div class="card">
            <img src="${freshImageUrl()}" alt="Jack Pope" width="80" height="80" />
            <p>Jack Pope</p>
        </div>`;
    }
    return (
        <>
            <button onClick={show}>Show profile (without React)</button>
            <div ref={ref} />
        </>
    );
}
1
2
3
4
5
// Add a unique parameter so the image isn't cached,
// and every run shows the loading state.
export function freshImageUrl() {
    return 'https://react.dev/images/team/jack-pope.jpg?t=' + Date.now();
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#root {
  min-height: 390px;
}
.card {
  margin-top: 1em;
}
.card img {
  display: block;
  border-radius: 50%;
  background: #dfe3e9;
}
.card p {
  font-weight: bold;
}
.avatar-placeholder {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  background: #dfe3e9;
}
.name-placeholder {
  width: 90px;
  border-radius: 4px;
  background: #dfe3e9;
}
hr {
  margin: 16px 0;
}
1
2
3
4
5
6
7
{
  "dependencies": {
    "react": "19.3.0-canary-f1f7ed2a-20260904",
    "react-dom": "19.3.0-canary-f1f7ed2a-20260904",
    "react-scripts": "latest"
  }
}

Согласование шрифтов, изображений и таблиц стилей

Граница приостановки может одновременно ждать данные, таблицы стилей, шрифты и изображения. Ожидание шрифтов и изображений бывает только во время обновления <ViewTransition>. В примере ниже компонент ProfileCard приостанавливается, пока загружаются его данные, и рендерит таблицу стилей с precedence, текст новым шрифтом и портрет. React держит скелет видимым, пока загружаются данные и таблица стилей. Затем раскрытие <ViewTransition> ждёт шрифт и изображение, чтобы карточка появилась целиком.

Для сравнения версия без React загружает те же данные и показывает, как каждый ресурс приходит по своему расписанию:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import { ViewTransition, Suspense, use, useState, startTransition } from 'react';
import { fetchQuote } from './data.js';
import { freshStylesheetUrl, freshImageUrl } from './resources.js';
import VanillaProfileCard from './VanillaProfileCard.js';

function ProfileCard({ resources }) {
    const quote = use(resources.quotePromise);
    return (
        <>
            <link rel="stylesheet" href={resources.stylesheet} precedence="default" />
            <div className="profile-card">
                <img src={resources.image} alt="Jack Pope" width={80} height={80} />
                <div>
                    <p className="name">Jack Pope</p>
                    <p className="bio">{quote}</p>
                </div>
            </div>
        </>
    );
}

function ProfileCardPlaceholder() {
    return (
        <div className="profile-card">
            <div className="avatar-placeholder" />
            <div>
                <p className="name name-placeholder">&nbsp;</p>
                <p className="bio bio-placeholder">&nbsp;</p>
            </div>
        </div>
    );
}

export default function App() {
    const [resources, setResources] = useState(null);
    return (
        <>
            <button
                onClick={() => {
                    startTransition(() => {
                        setResources({
                            quotePromise: fetchQuote(),
                            stylesheet: freshStylesheetUrl(),
                            image: freshImageUrl(),
                        });
                    });
                }}>
                Show profile
            </button>
            {resources && (
                <ViewTransition>
                    <Suspense fallback={<ProfileCardPlaceholder />}>
                        <ProfileCard resources={resources} />
                    </Suspense>
                </ViewTransition>
            )}
            <hr />
            <VanillaProfileCard />
        </>
    );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { useRef } from 'react';
import { fetchQuote } from './data.js';
import { freshStylesheetUrl, freshImageUrl } from './resources.js';

export default function VanillaProfileCard() {
    const ref = useRef(null);
    async function show() {
        const quote = await fetchQuote();
        const doc = ref.current.contentWindow.document;
        doc.open();
        doc.write(`
            <style>
                body { margin: 0; font-family: sans-serif; }
                .profile-card { display: flex; gap: 12px; align-items: center; }
                .profile-card img { border-radius: 50%; background: #dfe3e9; }
                .name { margin: 0 0 4px; font-family: 'Caveat', sans-serif; font-size: 22px; line-height: 28px; font-weight: bold; }
                .bio { margin: 0; font-family: 'Caveat', sans-serif; font-size: 20px; line-height: 26px; }
            </style>
            <div class="profile-card">
                <img src="${freshImageUrl()}" alt="Jack Pope" width="80" height="80" />
                <div>
                    <p class="name">Jack Pope</p>
                    <p class="bio">${quote}</p>
                </div>
            </div>
            <link rel="stylesheet" href="${freshStylesheetUrl()}">
        `);
        doc.close();
    }
    return (
        <>
            <button onClick={show}>Show profile (without React)</button>
            <iframe ref={ref} title="Vanilla profile card" className="vanilla-frame" />
        </>
    );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Add a unique parameter so the resources aren't cached,
// and every run shows the loading state.
export function freshStylesheetUrl() {
    return (
        'https://fonts.googleapis.com/css2?family=Caveat&display=swap' +
        '&t=' +
        Date.now()
    );
}

export function freshImageUrl() {
    return 'https://react.dev/images/team/jack-pope.jpg?t=' + Date.now();
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Note: the way you would do data fetching depends on
// the framework that you use together with Suspense.

export async function fetchQuote() {
    // Add a fake delay to make waiting noticeable.
    await new Promise((resolve) => {
        setTimeout(resolve, 1000);
    });
    return 'The best way to predict the future is to invent it.';
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#root {
  min-height: 320px;
}
button {
  margin-right: 8px;
}
hr {
  margin: 16px 0;
}
.profile-card {
  display: flex;
  gap: 12px;
  align-items: center;
  margin-top: 1em;
}
.profile-card img {
  border-radius: 50%;
  background: #dfe3e9;
}
.name {
  margin: 0 0 4px;
  font-family: 'Caveat', sans-serif;
  font-size: 22px;
  line-height: 28px;
  font-weight: bold;
}
.bio {
  margin: 0;
  font-family: 'Caveat', sans-serif;
  font-size: 20px;
  line-height: 26px;
}
.profile-card img {
  display: block;
}
.avatar-placeholder {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  background: #dfe3e9;
}
.name-placeholder,
.bio-placeholder {
  border-radius: 4px;
  background: #dfe3e9;
  color: transparent;
}
.name-placeholder {
  width: 90px;
}
.bio-placeholder {
  width: 220px;
}
.vanilla-frame {
  display: block;
  margin-top: 1em;
  border: none;
  width: 100%;
  height: 110px;
}
1
2
3
4
5
6
7
{
  "dependencies": {
    "react": "19.3.0-canary-f1f7ed2a-20260904",
    "react-dom": "19.3.0-canary-f1f7ed2a-20260904",
    "react-scripts": "latest"
  }
}

Устранение неполадок

Как предотвратить замену пользовательского интерфейса на fallback во время обновления?

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

Чтобы этого не произошло, пометьте обновление как несрочное с помощью startTransition. Во время перехода React будет ждать, пока загрузится достаточно данных, чтобы предотвратить появление нежелательного отката:

1
2
3
4
5
6
function handleNextPageClick() {
    // If this update suspends, don't hide the already displayed content
    startTransition(() => {
        setCurrentPage(currentPage + 1);
    });
}

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

React будет предотвращать нежелательные отступления только во время несрочных обновлений. Он не будет задерживать рендеринг, если он является результатом срочного обновления. Вы должны выбрать API, например startTransition или useDeferredValue.

Если ваш маршрутизатор интегрирован с Suspense, он должен автоматически обернуть свои обновления в startTransition.

Источник — https://react.dev/reference/react/Suspense

Комментарии