useSyncExternalStore
useSyncExternalStore — благословлённый React примитив, чтобы безопасно подписываться на любой внешний стор при конкурентном рендеринге: subscribe + getSnapshot + getServerSnapshot. Стабильные снимки спасают от циклов рендера; getServerSnapshot — от падения SSR.
Часть состояния — не твоя. navigator.onLine, запрос matchMedia, URL через History API, WebSocket-клиент, сторонний стор из-за пределов мира React — ничем из этого React не владеет, поэтому не может узнать, когда оно меняется. Рефлекс — useEffect + useState: подписаться в эффекте, скопировать значение в состояние, перерисоваться. Это работает в демо, а потом рвётся под нагрузкой.
При конкурентном рендеринге React может приостанавливать, перезапускать и переплетать рендеры. Если твой компонент читает внешнее значение во время рендера и это значение меняется посреди рендера, две части дерева могут закоммититься с двумя разными значениями — разрыв (tearing). Подход с зеркалом через useEffect к тому же читает устаревшее на самом первом рендере и ломается под SSR. useSyncExternalStore — примитив, который React выпустил специально, чтобы сделать внешние подписки безопасными от разрыва, и именно его под капотом вызывает каждая библиотека сторов.
После этого урока ты можешь подписать компонент на любой внешний (не-React) стор с помощью useSyncExternalStore, передав три его аргумента — subscribe, getSnapshot и getServerSnapshot; объяснить, почему это правильный, безопасный от разрыва мост при конкурентном рендеринге, а не useEffect + useState; распознать два режима отказа, на которые натыкаются все (getSnapshot, возвращающий свежий объект на каждый вызов → бесконечный цикл рендера, и отсутствующий getServerSnapshot → падение SSR); и знать, когда тянуться к нему напрямую, а когда дать библиотеке сделать это за тебя.
Сигнатура — три функции: subscribe, getSnapshot и (для SSR) getServerSnapshot. subscribe(cb) регистрирует колбэк, который React вызывает при каждом изменении стора, и возвращает функцию отписки. getSnapshot() синхронно возвращает текущее значение. getServerSnapshot() возвращает значение для рендеринга на сервере и первой клиентской гидратации, где браузерных API не существует.
"use client";
import { useSyncExternalStore } from "react";
function useOnlineStatus(): boolean {
return useSyncExternalStore(
// subscribe: соединяем стор -> React, возвращаем очистку
(onStoreChange) => {
window.addEventListener("online", onStoreChange);
window.addEventListener("offline", onStoreChange);
return () => {
window.removeEventListener("online", onStoreChange);
window.removeEventListener("offline", onStoreChange);
};
},
() => navigator.onLine, // getSnapshot (клиент)
() => true, // getServerSnapshot (на сервере navigator нет)
);
}React подписывается один раз, читает снимок на каждом рендере и перерисовывает компонент, когда срабатывает onStoreChange. Никакого эффекта, никакого зеркального useState, никакого устаревшего первого рендера.
Почему не useEffect + useState? Потому что это зеркало рвётся при конкурентном рендеринге и устаревает на первой отрисовке. Эффект выполняется после первого коммита, так что первый рендер показывает то значение по умолчанию, которым ты его засеял, а не настоящее. Хуже того: поскольку значение живёт в твоей собственной копии состояния, конкурентный рендер, начавшийся до обновления, может закоммитить старое значение, пока другая часть дерева коммитит новое, — один и тот же стор прочитан как два разных. useSyncExternalStore читает снимок синхронно во время рендера и форсирует согласованное, неразорванное чтение по всему коммиту.
// ❌ рефлекс: подписка-в-эффекте, зеркало в состоянии
function useOnlineStatusBad() {
const [online, setOnline] = useState(navigator.onLine); // на сервере тоже падает
useEffect(() => {
const on = () => setOnline(navigator.onLine);
window.addEventListener("online", on);
window.addEventListener("offline", on);
return () => {
window.removeEventListener("online", on);
window.removeEventListener("offline", on);
};
}, []);
return online; // устаревшее до запуска эффекта; может рваться при конкурентных рендерах
}Именно поэтому собственная документация React перечисляет это в разделе «возможно, эффект тебе не нужен»: подписка на внешний стор — в точности тот случай, где зеркало-через-эффект — неправильный инструмент, а этот хук — правильный.
getSnapshot ОБЯЗАН возвращать стабильную ссылку для неизменившихся данных — свежий объект на каждый вызов — это бесконечный цикл рендера. После каждого рендера React вызывает getSnapshot и сравнивает результат с предыдущим через Object.is. Если это новое значение, React предполагает, что стор изменился, и перерисовывается — что снова вызывает getSnapshot — что возвращает ещё один новый объект — и так вечно. Лечение — возвращать ту же ссылку, пока сами данные действительно не изменятся (кешировать их в сторе), либо выбрать примитив.
// ❌ новая идентичность массива на каждый вызов -> Object.is всегда false -> цикл
const todos = useSyncExternalStore(store.subscribe, () => store.todos.filter(t => !t.done));
// ✅ возвращаем кешированную ссылку; пересчитываем только при мутации стора
const todos = useSyncExternalStore(store.subscribe, store.getVisibleTodos);
// где getVisibleTodos мемоизирует и выдаёт новый массив только при изменении todos
// ✅ или читаем примитив, который сравнивается по значению
const count = useSyncExternalStore(store.subscribe, () => store.todos.length);Если тебе действительно нужен производный объект на каждый рендер, выводи его снаружи хука через useMemo из стабильного снимка — никогда внутри getSnapshot. (useSyncExternalStoreWithSelector из use-sync-external-store/shim/with-selector существует ровно для случая «выбрать и сравнить».)
Пропусти getServerSnapshot в любом SSR/RSC-клиентском приложении — и обрушишь серверный рендер. Во время серверного рендеринга и первого прохода гидратации нет ни window, ни navigator, так что getSnapshot бросает исключение (navigator is not defined) — или, если не бросает, серверный HTML и первый клиентский рендер расходятся, и ты получаешь рассогласование гидратации. getServerSnapshot поставляет безопасное, детерминированное значение для сервера и гидратации; затем React после монтирования перечитывает getSnapshot и обновляется, если настоящее значение отличается.
return useSyncExternalStore(
subscribe,
() => navigator.onLine, // клиентский снимок — только в браузере
() => true, // серверный снимок — считаем, что онлайн во время SSR/гидратации
);В Next.js App Router этот хук выполняется только в "use client"-компоненте, но клиентские компоненты всё равно рендерятся на сервере для исходного HTML, так что в этом мире третий аргумент не опционален — это разница между чистой гидратацией и падением в рантайме на первом запросе.
Замости крошечный кастомный стор, потом читай его из двух компонентов без разрыва. Допустим, у тебя есть не-React стор — несколько подписчиков и значение. Это та форма, которую оборачивает каждая библиотека сторов; сделав это руками один раз, делаешь абстракцию очевидной.
// store.ts — обычный модуль, ноль React
type Listener = () => void;
function createCounterStore() {
let count = 0;
const listeners = new Set<Listener>();
return {
increment() {
count += 1;
listeners.forEach((l) => l()); // уведомляем React
},
subscribe(listener: Listener) {
listeners.add(listener);
return () => listeners.delete(listener); // React вызывает это для очистки
},
getSnapshot() {
return count; // примитив -> сравнивается по значению, всегда стабилен
},
};
}
export const counterStore = createCounterStore();Теперь хук и два потребителя, которым гарантированно читать одно и то же значение в одном коммите:
"use client";
import { useSyncExternalStore } from "react";
import { counterStore } from "./store";
function useCounter() {
return useSyncExternalStore(
counterStore.subscribe,
counterStore.getSnapshot,
() => 0, // getServerSnapshot: детерминированное начальное значение для SSR/гидратации
);
}
function Display() {
const count = useCounter();
return <span>Count: {count}</span>;
}
function Controls() {
const count = useCounter(); // тот же стор, тот же снимок — рваться не может
return <button onClick={() => counterStore.increment()}>+ ({count})</button>;
}Сравни с «до»: зеркало useEffect/useState в каждом компоненте держало бы свою собственную копию count, и при конкурентном рендере одна могла бы обновиться раньше другой — два значения на экране для одного стора. С useSyncExternalStore оба вызова useCounter читают один синхронный снимок в одном и том же коммите, так что Display и Controls всегда согласны. Мы передали примитивный снимок, поэтому ловушки идентичности нет; getServerSnapshot возвращает 0, так что SSR и гидратация совпадают.
▸Почему это работает
Зачем это нужно, если библиотеки вроде Zustand, Jotai, Redux и Valtio и так управляют внешним состоянием? Потому что они вызывают useSyncExternalStore внутри себя — это официальный, стабильный между версиями контракт между не-React состоянием и рендерером React, единственный API, способный гарантировать неразорванное чтение при конкурентных возможностях. Ты тянешься к нему напрямую, когда интегрируешь что-то, что вообще не библиотека состояния: браузерный API (onLine, matchMedia, localStorage, document.visibilityState), не-React эмиттер событий, игровой цикл, WebSocket-клиент или свой собственный стор на 30 строк. Если библиотека уже оборачивает твой стор — используй библиотеку; этот хук — для голого металла под ней.
▸Частая ошибка
Самый частый баг — определять subscribe или getSnapshot инлайн в теле компонента. Новая функция subscribe на каждый рендер заставляет React сносить и заново добавлять подписку при каждом рендере; getSnapshot, замыкающий и возвращающий свеже-собранный объект, зацикливается навсегда. Подними обе функции в область видимости модуля или оберни их в useCallback/useMemo и заставь getSnapshot возвращать кешированную ссылку (или примитив). Если нужен выбранный/производный срез, используй useSyncExternalStoreWithSelector, а не вычисляй внутри getSnapshot.
Компонент на useSyncExternalStore замораживает вкладку — React перерисовывает его в тугом цикле. Переданный getSnapshot — это () => store.items.filter(i => i.active). В чём причина и лечение?
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?) — благословлённый React примитив, чтобы подписать компонент на любой внешний, не-React стор безопасно при конкурентном рендеринге. subscribe(cb) соединяет события изменения стора с React и возвращает очистку; getSnapshot() синхронно читает текущее значение во время рендера, так что чтение безопасно от разрыва — каждая часть одного коммита видит одно и то же значение, чего зеркало useEffect + useState гарантировать не может (а ещё это зеркало устаревает на первой отрисовке и небезопасно под SSR). Корректное использование задают два режима отказа: getSnapshot обязан возвращать стабильную ссылку для неизменившихся данных, иначе получишь бесконечный цикл рендера, а getServerSnapshot обязан передаваться в любом SSR/RSC-клиентском приложении, иначе серверный рендер падает / рассогласуется на гидратации. Тянись к нему напрямую, когда мостишь браузерный API, эмиттер событий или свой крошечный стор; дай библиотеке сторов потянуться за тобой, когда ты уже её используешь, — потому что каждая из них под капотом вызывает именно этот хук.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.