In one minute
Some state lives outside React: the browser’s online status, localStorage, a plain JavaScript store, a third-party library. useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?) lets components read it and re-render when it changes:
subscribe(onChange)connects to the store and returns an unsubscribe function.getSnapshot()returns the current value. When nothing changed, it must return the same value.getServerSnapshot()gives the value to use on the server and during hydration.
It’s safer than useEffect + useState, because React can check the store during rendering and never show two different versions at once (“tearing”). State libraries like Redux and Zustand are built on it.
You’ll be able to: subscribe to a browser API and to your own store, and avoid the two classic mistakes: an unstable snapshot and a missing server snapshot.
1let state = { count: 0 }2const listeners = new Set()3const cartStore = {4 getSnapshot: () => state,5 subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener) },6 add() {7 state = { count: state.count + 1 } // new only when it changes8 listeners.forEach(l => l())9 },10}1112function useCartCount() {13 return useSyncExternalStore(cartStore.subscribe, cartStore.getSnapshot).count14}- event
state = { count: state.count + 1 } // new only when it changesThe store replaces its state with a new object, then calls every listener. React doesn’t own this state. - schedule
listeners.forEach(l => l())Each listener is React asking: did your snapshot change? - render phase
getSnapshot: () => state,getSnapshotreturns the store’s own object, a different one than last time, so React re-renders the components that read it, in both roots. - commit phaseRecorded:
render HeaderBadge (1),render ProductPage (1). When nothing changes,getSnapshotreturns the same object and React does nothing.
1function useCartBad() {2 return useSyncExternalStore(3 cartStore.subscribe,4 () => ({ ...cartStore.getSnapshot() }), // a new copy every call5 )6}- render phase
() => ({ ...cartStore.getSnapshot() }), // a new copy every callEvery call returns a new object. In development React calls it twice to check it’s stable; two copies are neverObject.is-equal. - render phase
return useSyncExternalStore(So React thinks the store changed on every render, and schedules another render, again and again. - commit phaseRecorded:
Maximum update depth exceeded. Return the store’s own object, and create a new one only when something changes.
The example
1. Online status
function subscribe(onChange: () => void) {
window.addEventListener('online', onChange)
window.addEventListener('offline', onChange)
return () => {
window.removeEventListener('online', onChange)
window.removeEventListener('offline', onChange)
}
}
function useOnlineStatus() {
return useSyncExternalStore(
subscribe,
() => navigator.onLine, // on the client
() => true, // on the server, and during hydration
)
}
function CheckoutButton() {
const online = useOnlineStatus()
return <button disabled={!online}>{online ? 'Place order' : 'Offline: reconnect to order'}</button>
}
subscribe is defined outside the component, so it’s the same function every render, and React doesn’t resubscribe. Recorded while Chrome was switched offline and back:
{
"logs": [],
"warnings": [],
"before": "Place order",
"offline": {
"text": "Offline: reconnect to order",
"disabled": true
},
"back": "Place order"
}
2. A cart store shared by two React roots
Some pages have more than one React root: say, an older header and a new product page. They can’t share React state or context, but they can share a store.
type Cart = { count: number }
function createCartStore() {
let state: Cart = { count: 0 }
const listeners = new Set<() => void>()
return {
getSnapshot: () => state,
subscribe(listener: () => void) {
listeners.add(listener)
return () => listeners.delete(listener)
},
add() {
state = { count: state.count + 1 } // a new object only when something changed
listeners.forEach((l) => l())
},
}
}
const cartStore = createCartStore()
function useCartCount() {
return useSyncExternalStore(cartStore.subscribe, cartStore.getSnapshot).count
}
function HeaderBadge() {
const count = useCartCount()
log(`render HeaderBadge (${count})`)
return <span id="badge">🛒 {count}</span>
}
function ProductPage() {
const count = useCartCount()
log(`render ProductPage (${count})`)
return <button id="add" onClick={cartStore.add}>Add to cart ({count} so far)</button>
}
// Two separate React apps on one page, e.g. an older header and a new product page
function mountBoth(header: HTMLElement, page: HTMLElement) {
createRoot(header).render(<HeaderBadge />)
createRoot(page).render(<ProductPage />)
}
Clicking “Add to cart” in the product page:
render HeaderBadge (1)
render ProductPage (1)
Both roots re-rendered. The header badge now shows 🛒 1.
3. Mistake: a new snapshot on every call
function useCartBad() {
return useSyncExternalStore(cartStore.subscribe, () => ({ ...cartStore.getSnapshot() }))
}
Recorded:
The result of getSnapshot should be cached to avoid an infinite loop
uncaught: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.
Every call returns a new object, so to React the store looks like it changes on every render, which schedules another render, and so on. The store above avoids this by creating a new state object only in add().
4. Mistake: no server snapshot
function useOnlineStatusClientOnly() {
return useSyncExternalStore(subscribe, () => navigator.onLine)
}
Rendering both buttons with renderToString, as a server would:
log('with getServerSnapshot:', renderToString(<CheckoutButton />))
try {
log('without:', renderToString(<ClientOnlyButton />))
} catch (e) {
log('without: threw', (e as Error).message)
}
with getServerSnapshot: <button>Place order</button>
without: threw Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering.
The server has no navigator.onLine. With getServerSnapshot, it renders “Place order”, and the client updates after hydration if the real value is different. Without it, server rendering fails, and React falls back to rendering that part on the client.
How it works
- Subscribe once, read on every render. React calls
subscribeafter mounting (and again only if you pass a differentsubscribefunction). On every render it callsgetSnapshotand compares the result with the last one usingObject.is. - Changes come from the store. When the store calls the listener, React calls
getSnapshotagain. If the snapshot changed, it re-renders that component. Updates from external stores are rendered synchronously, never as a background transition, so the screen can’t show a mix of old and new store values. - The snapshot must be immutable and cached. Return the store’s own value, and replace it with a new object only when something changes. In development, React calls
getSnapshottwice and warns if the results differ (recorded). - Select small values.
useCartCount()returnscount, a number, so components re-render only when the number changes. For derived objects, memoize them in the store, or use a library’s selector API.
Common mistakes
- Building a new object or array in
getSnapshot:() => ({ ...state }),() => items.filter(…). This causes an infinite loop (recorded). - Defining
subscribeinside the component withoutuseCallback: React unsubscribes and resubscribes on every render. - Leaving out
getServerSnapshotin server-rendered apps (recorded error). - Using it for state React could own. If the state is only used by your components,
useState,useReduceror context is simpler.
Interview Q&A
What problem does useSyncExternalStore solve?
Reading state that lives outside React and re-rendering when it changes, without tearing during concurrent rendering. A hand-rolled useEffect + useState subscription can miss updates between render and subscribe, and can show inconsistent values.
What are its arguments?
subscribe(callback) registers a listener and returns an unsubscribe function, and should be stable. getSnapshot() returns the current value and must return the same value when nothing changed. getServerSnapshot() is optional; it supplies the value for server rendering and hydration.
What happens if getSnapshot returns a new object every call?
React sees a change on every render and loops. Recorded: the warning “The result of getSnapshot should be cached to avoid an infinite loop”, then “Maximum update depth exceeded”.
Why does getServerSnapshot matter?
There’s no browser store on the server, and the first client render must match the server HTML. Recorded: without it, renderToString threw “Missing getServerSnapshot, which is required for server-rendered content.”
Do you usually call it directly?
Not often. Redux, Zustand and similar libraries call it inside their hooks. Calling it directly is reasonable for browser APIs (online status, media queries, localStorage) and small app-specific stores. Recorded: one store kept two separate React roots in sync.
Related
- Context with use: shared state inside React.
- Side Effects: why subscriptions need cleanup.
- Scheduler, Lanes and Batching: why store updates are rendered synchronously.
Sources
- react.dev:
useSyncExternalStore, Subscribing to an external store - The
useSyncExternalStoreworking-group discussion: What is tearing? - Topic order inspired by Kent C. Dodds’ EpicReact Advanced React APIs workshop; the example app and code here are this handbook’s own.
Primary source: react.dev/reference/react/useSyncExternalStore