open atlas
↑ К треку
Паттерны React RXP · 07 · 03

useSyncExternalStore

useSyncExternalStore — благословлённый React примитив, чтобы безопасно подписываться на любой внешний стор при конкурентном рендеринге: subscribe + getSnapshot + getServerSnapshot. Стабильные снимки спасают от циклов рендера; getServerSnapshot — от падения SSR.

RXP Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Часть состояния — не твоя. 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); и знать, когда тянуться к нему напрямую, а когда дать библиотеке сделать это за тебя.

1

Сигнатура — три функции: 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, никакого устаревшего первого рендера.

2

Почему не 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 перечисляет это в разделе «возможно, эффект тебе не нужен»: подписка на внешний стор — в точности тот случай, где зеркало-через-эффект — неправильный инструмент, а этот хук — правильный.

3

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 существует ровно для случая «выбрать и сравнить».)

4

Пропусти 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.