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 один раз рендерит с устаревшим значением и может на мгновение показывать разные значения в разных компонентах.