ReactMiddleTechnical

Как работает useSyncExternalStore и зачем он нужен библиотекам состояния?

useSyncExternalStore позволяет безопасно подписываться на внешние хранилища в конкурентном режиме React 18+, гарантируя отсутствие tearing. Принимает subscribe, getSnapshot и опциональный getServerSnapshot; Zustand, Redux и другие библиотеки используют его как основу для интеграции с React.

Зачем появился useSyncExternalStore

До React 18 библиотеки состояния (Redux, Zustand, MobX) подписывались на внешние хранилища через useEffect + useState или useReducer. В конкурентном режиме React 18 это порождало tearing — ситуацию, когда разные компоненты одного дерева читают разные версии одного хранилища в рамках одного рендера. useSyncExternalStore решает эту проблему: React гарантирует, что все компоненты, подписанные на одно хранилище, видят одну и ту же снимок данных.

API хука

const snapshot = useSyncExternalStore(
  subscribe,       // (onStoreChange) => unsubscribe
  getSnapshot,     // () => currentValue — вызывается синхронно во время рендера
  getServerSnapshot // () => serverValue — для SSR (опционально)
);
  • subscribe — функция, которая регистрирует коллбэк onStoreChange и возвращает функцию отписки. React вызывает коллбэк каждый раз, когда хранилище изменилось.
  • getSnapshot — синхронная функция, возвращающая текущее значение. Должна возвращать стабильную ссылку (кэшировать), если данные не изменились — иначе вызовет бесконечный цикл ре-рендеров.
  • getServerSnapshot — значение для серверного рендеринга; если не передано и хук используется на сервере, React бросает ошибку.

Пример: минимальное внешнее хранилище

// store.js — простое хранилище без React
let state = { count: 0 };
const listeners = new Set();

export function getSnapshot() {
  return state; // ВАЖНО: одна и та же ссылка пока не изменилась
}

export function subscribe(onStoreChange) {
  listeners.add(onStoreChange);
  return () => listeners.delete(onStoreChange);
}

export function increment() {
  state = { count: state.count + 1 }; // новая ссылка при изменении
  listeners.forEach((l) => l());
}
// Counter.jsx
import { useSyncExternalStore } from "react";
import { subscribe, getSnapshot, increment } from "./store";

export function Counter() {
  const { count } = useSyncExternalStore(subscribe, getSnapshot);

  return <button onClick={increment}>Count: {count}</button>;
}

Как Zustand использует useSyncExternalStore

Zustand (начиная с v4) внутренне реализует свой useStore через useSyncExternalStore. Каждый вызов create создаёт хранилище с методами getState, setState и subscribe. Компонент, вызывающий useStore(selector), подписывается через useSyncExternalStore с результатом селектора в качестве снимка.

// Упрощённая внутренняя реализация Zustand-подобной библиотеки
function createStore(initializer) {
  let state = initializer();
  const listeners = new Set();

  const getState = () => state;
  const setState = (partial) => {
    state = { ...state, ...(typeof partial === "function" ? partial(state) : partial) };
    listeners.forEach((l) => l());
  };
  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  };

  function useStore(selector = (s) => s) {
    return useSyncExternalStore(
      subscribe,
      () => selector(getState()),
      () => selector(getState())
    );
  }

  return { getState, setState, useStore };
}

SSR и getServerSnapshot

При серверном рендеринге getSnapshot не вызывается — React использует getServerSnapshot. Если хранилище не имеет смысла на сервере (например, хранит состояние DOM), нужно возвращать дефолтное значение.

const width = useSyncExternalStore(
  subscribeToResize,
  () => window.innerWidth,
  () => 1024 // серверный снимок: дефолтная ширина
);

Подводные камни

  • Нестабильный снимок из getSnapshot — если getSnapshot создаёт новый объект/массив при каждом вызове, React получает новую ссылку, считает состояние изменённым и входит в бесконечный цикл ре-рендеров. Результат нужно кэшировать, если данные не изменились.
  • Отсутствие getServerSnapshot при SSR — React выбросит ошибку. Нужно либо передать третий аргумент, либо динамически импортировать компонент с ssr: false.
  • Мутация состояния вместо замены — если setState мутирует объект вместо создания нового, getSnapshot вернёт ту же ссылку, React не обнаружит изменение и не перерисует компонент.
  • Подписка без отпискиsubscribe должна возвращать функцию отписки. Если этого не сделать, React не сможет очистить подписку при анмаунте, что приведёт к утечке памяти и вызовам setState на размонтированном компоненте.
  • Использование useEffect для подписки (до React 18) — в конкурентном режиме между рендером и эффектом может пройти время, за которое хранилище успеет обновиться; компонент пропустит обновление. useSyncExternalStore закрывает это окно.
  • Тяжёлые селекторы без мемоизации — если селектор вычисляет производные данные (фильтрует список), он будет вызываться при каждой любой мутации хранилища. Мемоизируйте селекторы через createSelector (reselect) или аналоги.

Common mistakes

  • Отвечать определением без production-сценария.
  • Не называть runtime boundary, security boundary или failure mode.
  • Игнорировать версию API, observability и тестовую проверку.

What the interviewer is testing

  • Объясняет механизм своими словами и без выдуманных API.
  • Называет реальные риски, диагностику и критерий корректности.
  • Связывает ответ с текущей документацией и миграционными ограничениями.

Sources

Related topics