Good to knowAdvanced React APIs · 07

useSyncExternalStore

Read state React doesn’t own (the browser, a plain JS store) safely, with stable snapshots and a server value.

On this page · 7 sections
  1. In one minute
  2. The example
  3. How it works
  4. Common mistakes
  5. Interview Q&A
  6. Related
  7. Sources

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:

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.

Stable snapshot
cartStore.ts
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}
What you see
🛒 0
Add to cart (0 so far)
Rendered this step
HeaderBadge (root 1)–
ProductPage (root 2)–
  1. eventstate = { 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.
  2. schedulelisteners.forEach(l => l())Each listener is React asking: did your snapshot change?
  3. render phasegetSnapshot: () => state,getSnapshot returns the store’s own object, a different one than last time, so React re-renders the components that read it, in both roots.
  4. commit phaseRecorded: render HeaderBadge (1), render ProductPage (1). When nothing changes, getSnapshot returns the same object and React does nothing.

0 / 0
A cart store written in plain JavaScript, read by two separate React roots on one page (both versions recorded).

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

Common mistakes

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.

Sources

Primary source: react.dev/reference/react/useSyncExternalStore