Menu

useSyncExternalStore в React: подписка на внешние данные

useSyncExternalStore подписывает компонент на данные, которые живут вне React, например небольшой модуль-хранилище или API браузера вроде navigator.onLine, и рендерит его снова при каждом изменении этих данных. subscribe и getSnapshot, почему снимок нужно кэшировать и getServerSnapshot.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

useSyncExternalStore подписывает компонент на данные, которые живут вне React, и рендерит его снова каждый раз, когда эти данные меняются. Вы даёте ему две функции: subscribe, которая говорит React, как слушать изменения, и getSnapshot, которая возвращает текущее значение.

Два компонента Display не делят ни пропсов, ни контекста, но оба обновляются вместе, потому что оба подписаны на одно и то же хранилище. Кнопка вызывает обычную функцию, а не сеттер React. Добавьте третий <Display name="Sidebar" />, и он присоединится без каких-либо других изменений.

Синтаксис

const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);
  • subscribe(callback) начинает слушать, вызывает callback каждый раз, когда данные могли измениться, и возвращает функцию отписки. React вызывает её после монтирования компонента, а возвращённую функцию вызывает при размонтировании.
  • getSnapshot() возвращает текущее значение. React вызывает её при каждом рендере и после каждого уведомления, затем сравнивает результат с последним через Object.is. То же значение, рендера нет.
  • getServerSnapshot() (необязательно) возвращает значение для сервера и для гидратации.

Объявляйте subscribe вне компонента или сохраняйте её стабильной через useCallback. Если передавать новую функцию subscribe при каждом рендере, React каждый раз отписывается и подписывается заново.

API браузера как хранилища

В эту форму укладывается всё, у чего есть текущее значение и что вызывает событие при изменении значения. Классический случай это navigator.onLine с событиями online и offline.

Отключите сеть (или переключитесь на Offline в инструментах разработчика браузера), и текст изменится без перезагрузки. Третий аргумент говорит «считать, что сеть есть» при рендеринге на сервере, где нет navigator. Обёртка вызова хука в useOnlineStatus превращает его в пользовательский хук, который может использовать любой компонент.

Ширина окна работает так же:

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

getSnapshot должен возвращать кэшированное значение

React часто вызывает getSnapshot и сравнивает результаты по ссылке. Функция, которая при каждом вызове строит новый объект или массив, всегда выглядит как изменение:

// Broken: a new object on every call
function getSnapshot() {
    return { count: store.count, user: store.user };
}

// Also broken: filter returns a new array every time
function getSnapshot() {
    return store.todos.filter((t) => !t.done);
}

React рендерит, вызывает getSnapshot, получает «другое» значение, рендерит снова и так далее, пока не остановится с "Maximum update depth exceeded". В разработке React сначала также выводит "The result of getSnapshot should be cached to avoid an infinite loop". Продакшен-сборка, как превью здесь, пропускает это предупреждение и сообщает только итоговую ошибку коротким кодом (Minified React error #185).

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

getSnapshot возвращает один и тот же объект state, пока add его не заменит, поэтому компонент рендерится один раз на изменение. Фильтрация происходит в компоненте, после чтения снимка, и это безопасно. Чтобы фильтровать внутри хранилища, вычисляйте отфильтрованный массив при изменении данных и сохраняйте его, чтобы getSnapshot мог возвращать сохранённую копию.

getServerSnapshot и гидратация

На сервере нет ни окна, ни navigator, ни подписки. Третий аргумент говорит React, что там рендерить:

const width = useSyncExternalStore(
    subscribe,
    () => window.innerWidth, // in the browser
    () => 1024 // on the server, and during hydration
);

React также использует getServerSnapshot для первого рендера в браузере, когда гидратирует HTML с сервера, чтобы они совпали. Сразу после гидратации он читает getSnapshot и, если настоящее значение отличается, рендерит снова с ним. Без третьего аргумента серверный рендер выбрасывает "Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering." Если над компонентом есть граница <Suspense>, сервер отправляет запасной вариант этой границы, а браузер вместо этого рендерит её содержимое; без границы серверный рендер падает.

useSyncExternalStore или useEffect и useState

Подписаться можно и эффектом:

function useOnlineStatus() {
    const [online, setOnline] = useState(true);
    useEffect(() => {
        const update = () => setOnline(navigator.onLine);
        update();
        window.addEventListener('online', update);
        window.addEventListener('offline', update);
        return () => {
            window.removeEventListener('online', update);
            window.removeEventListener('offline', update);
        };
    }, []);
    return online;
}

Это работает, но с двумя слабостями. Первый рендер всегда показывает начальную догадку, а настоящее значение приходит рендером позже, после выполнения эффекта. А при конкурентном рендеринге (например, во время перехода) React может приостановить рендер на полпути; если хранилище изменится во время паузы, компоненты, отрендеренные до и после, могут показывать разные значения. Такая несогласованность называется tearing (разрыв). useSyncExternalStore читает значение во время рендера и заставляет React синхронно переделать рендер, если хранилище изменилось, поэтому каждый компонент видит одно и то же значение.

Используйте его, когда данные живут вне React: ваш собственный модуль-хранилище, API браузера, сторонняя библиотека. Большинство библиотек состояния (Redux, Zustand и другие) вызывают его за вас внутри своих хуков. Для данных, которые принадлежат вашим компонентам, правильными инструментами остаются useState, useReducer и контекст.

Часто задаваемые вопросы

Для чего нужен useSyncExternalStore?

Для чтения данных, которыми React не владеет и которые могут меняться сами: хранилища, написанного вне React, сторонней библиотеки состояния или значения браузера вроде navigator.onLine или ширины окна. Компонент рендерится снова каждый раз, когда хранилище сообщает React, что оно изменилось.

Что делают subscribe и getSnapshot?

subscribe(callback) начинает слушать хранилище, вызывает callback при каждом изменении и возвращает функцию, которая прекращает прослушивание. getSnapshot() возвращает текущее значение. React вызывает getSnapshot во время рендера и после каждого уведомления и рендерит снова, только если значение изменилось по Object.is.

Почему getSnapshot должен возвращать кэшированное значение?

React сравнивает результат каждого вызова getSnapshot с предыдущим. Если он каждый раз возвращает новый объект или массив, React всегда видит изменение, рендерит снова, снова вызывает getSnapshot и зацикливается, пока не выбросит "Maximum update depth exceeded". Возвращайте одну и ту же ссылку, пока данные действительно не изменятся.

Что такое getServerSnapshot?

Необязательный третий аргумент. Он возвращает значение для серверного рендеринга и для гидратации в браузере, чтобы оба давали одинаковый HTML. Без него компонент на сервере выбрасывает "Missing getServerSnapshot", и содержимое под ближайшей границей <Suspense> рендерится вместо этого в браузере.

Что использовать: useSyncExternalStore или useEffect с useState?

Для подписки на внешние данные предпочитайте useSyncExternalStore. Он читает значение во время рендера, поэтому первый рендер уже правильный, и каждый компонент видит одно и то же значение даже при конкурентном рендеринге. useEffect с useState один раз рендерит с устаревшим значением и может на мгновение показывать разные значения в разных компонентах.

Иллюстрация языков программирования Coddy

Учитесь программировать с Coddy

НАЧАТЬ