useSyncExternalStore: зачем нужен, как использовать и чем отличается от useState
- React
- useSyncExternalStore
- Стор
- Состояние
В React 18 появился хук useSyncExternalStore — он решает задачу подписки компонента на внешнее хранилище (вне дерева React). В этой статье разберём, зачем он нужен, как им пользоваться и чем он принципиально отличается от useState.
Зачем нужен useSyncExternalStore
Обычно состояние в React живёт внутри компонентов: useState, useReducer, контекст. Но бывает, что источник правды — вне React: браузерные API (window.innerWidth, navigator.onLine), сторонние сторы (Zustand, Redux), подписки на события или WebSocket. Если просто читать такое значение в рендере и подписаться в useEffect, при конкурентном рендере (Concurrent Mode) React может «откатить» дерево и показать устаревшие данные — возникнет tearing (рассинхрон картинки).
useSyncExternalStore как раз и говорит React: «это значение приходит из внешнего источника, подпишись на него и при изменении перерисуй только то, что нужно». React гарантирует, что во время одного коммита все компоненты видят одно и то же значение из этого источника.
Сигнатура и базовый пример
Хук принимает три аргумента:
useSyncExternalStore<Snapshot>(
subscribe: (onStoreChange: () => void) => () => void,
getSnapshot: () => Snapshot,
getServerSnapshot?: () => Snapshot
): Snapshot
- subscribe — функция, которая подписывается на изменения хранилища. В аргумент
onStoreChangeнужно вызывать при каждом изменении (обычно это колбэк, который перерисует компонент). - getSnapshot — возвращает текущее значение для клиента.
- getServerSnapshot — то же для SSR; на сервере подписка не работает, поэтому нужно вернуть начальное значение.
Пример: подписка на ширину окна.
function useWindowWidth() {
return useSyncExternalStore(
(onStoreChange) => {
window.addEventListener('resize', onStoreChange)
return () => window.removeEventListener('resize', onStoreChange)
},
() => window.innerWidth,
() => 0, // на сервере нет window
)
}
function MyComponent() {
const width = useWindowWidth()
return <p>Ширина окна: {width}px</p>
}
При изменении размера окна вызовется onStoreChange, React перерисует только компоненты, использующие этот хук, и покажет актуальное значение.
Чем отличается от useState: перерисовка дерева
С useState при вызове setState обновляется состояние конкретного компонента. React помечает его и детей как нуждающихся в реконсиляции и идёт по дереву. Родители и «боковые» ветки без этого состояния не перерисовываются из-за этого вызова.
С useSyncExternalStore источник обновления — вне React. Когда внешнее хранилище меняется, вызывается колбэк onStoreChange, и перерисовываются только те компоненты, которые подписаны на этот стор через useSyncExternalStore. Остальное дерево не затронуто этим обновлением — React не поднимает состояние вверх и не тянет его из контекста; подписка точечная.
Ниже — схематичное сравнение: кружки это компоненты, стрелки — что перерисовывается при обновлении.
useState: при вызове setState в компоненте с состоянием перерисовываются он сам и всё поддерево (оранжевые узлы и стрелки). Остальные компоненты не затронуты.

useSyncExternalStore: при изменении внешнего стора перерисовываются только компоненты, подписанные на него (зелёные кружки). Остальное дерево не перерисовывается.

Иными словами:
- useState: обновление инициируется изнутри React (клик, эффект и т.д.); реконсилируются компонент и его поддерево по правилам React.
- useSyncExternalStore: обновление приходит снаружи; реконсилируются только компоненты, которые вызвали этот хук для данного хранилища. Дерево не перерисовывается целиком из-за внешнего источника — только подписчики.
Это даёт предсказуемую производительность: глобальный стор может обновляться часто, но обновятся только те части интерфейса, которые реально читают нужный кусок состояния.
Когда лучше использовать useSyncExternalStore
Имеет смысл использовать, когда:
- Состояние живёт вне React: браузерные API, глобальный стор (Zustand, Redux и т.п.), события, WebSocket.
- Нужна совместимость с Concurrent Mode и SSR: хук из коробки решает tearing и даёт корректное начальное значение на сервере через
getServerSnapshot. - Хотите точечные подписки: перерисовываются только компоненты, вызвавшие
useSyncExternalStoreдля этого хранилища.
Не стоит тянуть в него обычное локальное состояние формы или UI — для этого по-прежнему useState / useReducer.
Пример: минимальный внешний стор
Ниже — примитивный стор с подпиской; компоненты подписываются через useSyncExternalStore и перерисовываются только при изменении данных в сторе.
type Listener = () => void
function createStore<T>(initial: T) {
let state = initial
const listeners = new Set<Listener>()
return {
getSnapshot: () => state,
subscribe: (listener: Listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
setState: (next: T | ((prev: T) => T)) => {
state = typeof next === 'function' ? (next as (p: T) => T)(state) : next
listeners.forEach((l) => l())
},
}
}
const store = createStore(0)
function Counter() {
const value = useSyncExternalStore(
store.subscribe,
store.getSnapshot,
() => 0,
)
return (
<div>
<span>{value}</span>
<button onClick={() => store.setState((n) => n + 1)}>+1</button>
</div>
)
}
Здесь при клике обновляется только поддерево компонентов, которые вызывают useSyncExternalStore(store.subscribe, store.getSnapshot, ...). Остальное дерево не затронуто.
Итог
- useSyncExternalStore нужен для подписки на внешние источники данных с корректной работой в Concurrent Mode и SSR.
- В отличие от useState, обновление приходит снаружи; перерисовываются только компоненты, подписанные на этот стор, а не всё дерево.
- Используйте его для браузерных API, глобальных сторов и любых данных вне React; локальный UI-стейт оставьте за
useStateи контекстом.