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:
// ✗ 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.
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.
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 |
Subscribing to Window Resize
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 < 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:
// 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:
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'
)
}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,localStoragechanges via thestorageeventBridging 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
Key Takeaways
useSyncExternalStoresolves the "tearing" problem that arises when external mutable stores are read during concurrent rendersIt takes three arguments: subscribe, getSnapshot, and (optionally) getServerSnapshot
getSnapshotmust return a referentially stable value when nothing has changed — returning new objects every call causes infinite re-rendersLibraries 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