useSyncExternalStore: зачем нужен, как использовать и чем отличается от useState

4 мин чтения
  • 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 в компоненте с состоянием перерисовываются он сам и всё поддерево (оранжевые узлы и стрелки). Остальные компоненты не затронуты.

useState: перерисовка поддерева при setState — кружки компоненты, оранжевые перерисовываются

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

useSyncExternalStore: перерисовываются только подписчики — кружки компоненты, зелёные подписчики

Иными словами:

  • useState: обновление инициируется изнутри React (клик, эффект и т.д.); реконсилируются компонент и его поддерево по правилам React.
  • useSyncExternalStore: обновление приходит снаружи; реконсилируются только компоненты, которые вызвали этот хук для данного хранилища. Дерево не перерисовывается целиком из-за внешнего источника — только подписчики.

Это даёт предсказуемую производительность: глобальный стор может обновляться часто, но обновятся только те части интерфейса, которые реально читают нужный кусок состояния.

Когда лучше использовать useSyncExternalStore

Имеет смысл использовать, когда:

  1. Состояние живёт вне React: браузерные API, глобальный стор (Zustand, Redux и т.п.), события, WebSocket.
  2. Нужна совместимость с Concurrent Mode и SSR: хук из коробки решает tearing и даёт корректное начальное значение на сервере через getServerSnapshot.
  3. Хотите точечные подписки: перерисовываются только компоненты, вызвавшие 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 и контекстом.