ReactuseSyncExternalStore Hook

useSyncExternalStore Hook

useSyncExternalStore is a React 18 hook designed specifically for library authors who need to subscribe to external data stores — things like Zustand, Redux, browser APIs, or any mutable value that lives outside React's state model. Most application developers will never call this hook directly, but understanding it explains how modern state-management libraries work under the hood.

The Problem: Tearing in Concurrent Mode

Before React 18, the classic pattern for subscribing to an external store was useState + useEffect. This worked fine in React 17's synchronous rendering model:

JSX
// ✗ FRAGILE in React 18 concurrent mode
function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine)

  useEffect(() => {
    const handler = () => setIsOnline(navigator.onLine)
    window.addEventListener('online', handler)
    window.addEventListener('offline', handler)
    return () => {
      window.removeEventListener('online', handler)
      window.removeEventListener('offline', handler)
    }
  }, [])

  return isOnline
}

In React 18's concurrent renderer, a render can be interrupted and resumed. During a long render, the external store might change value mid-render. Some components will see the old value; others will see the new value. This inconsistency is called tearing — the UI shows two different versions of the same data simultaneously.

Warning
Tearing is a real bug in production apps that use concurrent features (useTransition, useDeferredValue, Suspense). If you are writing a library that reads from a mutable external store, you must use useSyncExternalStore or face potential tearing.
The Fix: useSyncExternalStore

React provides useSyncExternalStore to eliminate tearing. It forces React to synchronously read a consistent snapshot of the store at the right moment, even during concurrent renders.

JSX
import { useSyncExternalStore } from 'react'

// Signature:
// useSyncExternalStore(
//   subscribe,          // (callback) => unsubscribe
//   getSnapshot,        // () => currentValue (must be referentially stable)
//   getServerSnapshot   // optional — () => value for SSR
// )

function useOnlineStatus() {
  return useSyncExternalStore(
    // subscribe: called once; React passes a callback to re-render when the store changes
    (callback) => {
      window.addEventListener('online', callback)
      window.addEventListener('offline', callback)
      return () => {
        window.removeEventListener('online', callback)
        window.removeEventListener('offline', callback)
      }
    },
    // getSnapshot: must return the same value if nothing changed
    () => navigator.onLine,
    // getServerSnapshot: navigator is not available on the server
    () => true
  )
}

function NetworkStatus() {
  const isOnline = useOnlineStatus()
  return <p>You are {isOnline ? 'online' : 'offline'}.</p>
}
The Three Parameters Explained

Parameter

Type

Purpose

subscribe

(callback: () => void) => () => void

Register a listener; return the cleanup/unsubscribe function

getSnapshot

() => T

Return the current store value. Must be pure and return same reference if unchanged

getServerSnapshot

() => T

Optional. Return a stable value for SSR / hydration. Omit only for client-only stores

Note
getSnapshot must be stable: if the store has not changed, getSnapshot must return the exact same value (by reference for objects/arrays). If it returns a new object every call, React will re-render in an infinite loop. Always store computed objects outside the function or memoize them.
Subscribing to Window Resize

JSX
import { useSyncExternalStore } from 'react'

function getSnapshot() {
  return window.innerWidth   // primitive — stable as long as width hasn't changed
}

function subscribe(callback) {
  window.addEventListener('resize', callback)
  return () => window.removeEventListener('resize', callback)
}

function useWindowWidth() {
  return useSyncExternalStore(
    subscribe,
    getSnapshot,
    () => 0   // server snapshot — no window on server
  )
}

function ResponsiveLayout() {
  const width = useWindowWidth()

  return (
    <div>
      <p>Window width: {width}px</p>
      {width &lt; 768 ? <MobileNav /> : <DesktopNav />}
    </div>
  )
}
Building a Minimal External Store

To see the full picture, here is a tiny external store built from scratch — the same pattern that Zustand uses internally:

JSX
// store.js — a minimal external store
function createStore(initialState) {
  let state = initialState
  const listeners = new Set()

  function getState() {
    return state
  }

  function setState(updater) {
    state = typeof updater === 'function' ? updater(state) : updater
    listeners.forEach(l => l())   // notify all subscribers
  }

  function subscribe(listener) {
    listeners.add(listener)
    return () => listeners.delete(listener)   // unsubscribe
  }

  return { getState, setState, subscribe }
}

// Create a store (module-level — lives outside React)
const counterStore = createStore({ count: 0 })

// Hook to consume it
function useCounterStore() {
  return useSyncExternalStore(
    counterStore.subscribe,
    counterStore.getState,
    counterStore.getState   // same for SSR in this simple case
  )
}

// Component
function Counter() {
  const { count } = useCounterStore()

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => counterStore.setState(s => ({ count: s.count + 1 }))}>
        Increment
      </button>
    </div>
  )
}
How Popular Libraries Use It

Library

Version that adopted useSyncExternalStore

Notes

React-Redux

v8.0 (2022)

Replaced internal subscription logic to prevent tearing

Zustand

v4.0 (2022)

Core selector and subscribe mechanism rebuilt on this hook

Jotai

v1.6+ (partial)

Used for atoms that subscribe to external sources

XState

v5 actors

Used when binding actor state to React components

This is the key insight for application developers: if you are using Zustand 4+, React-Redux 8+, or Jotai, you are already benefiting from useSyncExternalStore — it is what makes these libraries safe to use with concurrent features. You do not need to call the hook yourself.

Server-Side Rendering Considerations

The third parameter, getServerSnapshot, is important for server-rendered apps. On the server, window, document, and other browser APIs do not exist. getServerSnapshot lets you provide a safe fallback value for the initial render:

JSX
function useThemePreference() {
  return useSyncExternalStore(
    (callback) => {
      const mq = window.matchMedia('(prefers-color-scheme: dark)')
      mq.addEventListener('change', callback)
      return () => mq.removeEventListener('change', callback)
    },
    // Client snapshot
    () => window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
    // Server snapshot — no matchMedia on server; default to light
    () => 'light'
  )
}
Warning
If you omit getServerSnapshot and the hook is used in a server-rendered component, React will throw. Always provide the third argument when there is any chance the component renders on the server.
When You Actually Need This Hook
  • Writing a state management library — you need tearing safety in concurrent mode

  • Subscribing to browser APIs that live outside React: navigator.onLine, window.innerWidth, document.visibilityState, localStorage changes via the storage event

  • Bridging a legacy non-React system that has its own pub/sub or event emitter pattern

  • Reading from a WebSocket or server-sent event stream where messages arrive outside the React render cycle

Tip
For most application code, reach for useState + useEffect, Zustand, React-Redux, or TanStack Query. useSyncExternalStore is the primitive those libraries are built on — not a replacement for them.
Key Takeaways
  • useSyncExternalStore solves the "tearing" problem that arises when external mutable stores are read during concurrent renders

  • It takes three arguments: subscribe, getSnapshot, and (optionally) getServerSnapshot

  • getSnapshot must return a referentially stable value when nothing has changed — returning new objects every call causes infinite re-renders

  • Libraries like Zustand 4+ and React-Redux 8+ use this hook internally; you benefit automatically

  • Application developers typically only need this when subscribing directly to browser APIs or building their own state primitives