Как работает 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.
- Называет реальные риски, диагностику и критерий корректности.
- Связывает ответ с текущей документацией и миграционными ограничениями.