useSyncExternalStore: External Store Integration in React (2026)
useSyncExternalStore is the official React hook for subscribing to any store or data source that lives outside React — browser APIs, third-party state managers, RxJS observables, or your own pub/sub systems. It was added in React 18 to fix tearing issues with concurrent rendering, replacing the old useEffect+useState pattern for external subscriptions. This guide covers the API in depth, practical browser API hooks, building a minimal Zustand-style store, and handling SSR with getServerSnapshot.
Table of Contents
The API
import { useSyncExternalStore } from 'react'
const snapshot = useSyncExternalStore(
subscribe, // (callback) => unsubscribe — called once on mount
getSnapshot, // () => T — must return same value if nothing changed
getServerSnapshot // () => T — optional, for SSR
)
// Rules:
// 1. subscribe must return an unsubscribe cleanup function
// 2. getSnapshot must be pure and return a stable reference when data hasn't changed
// (returning a new object/array every call causes infinite re-renders)
// 3. React calls getSnapshot after every external notification to check for changes
// — it compares with Object.is(), so primitives work; objects need memoization
// 4. getServerSnapshot is called during SSR / hydration instead of getSnapshot
// Why not useEffect + useState?
// In concurrent mode React can render a component multiple times before committing.
// useEffect fires after commit — there's a window where the external store changed
// but your state hasn't caught up, causing "tearing" (different components seeing
// different values from the same store). useSyncExternalStore eliminates this gap.
Online / Offline Status
import { useSyncExternalStore } from 'react'
function subscribeToOnline(callback: () => void) {
window.addEventListener('online', callback)
window.addEventListener('offline', callback)
return () => {
window.removeEventListener('online', callback)
window.removeEventListener('offline', callback)
}
}
function getOnlineSnapshot() {
return navigator.onLine
}
function getServerOnlineSnapshot() {
return true // Server is always "online"
}
export function useOnlineStatus() {
return useSyncExternalStore(subscribeToOnline, getOnlineSnapshot, getServerOnlineSnapshot)
}
// Usage
function NetworkBanner() {
const isOnline = useOnlineStatus()
if (isOnline) return null
return (
<div style={{
position: 'fixed', bottom: 0, left: 0, right: 0,
background: '#ef4444', color: '#fff', padding: '10px 16px',
textAlign: 'center', zIndex: 9999,
}}>
You're offline — changes will sync when you reconnect.
</div>
)
}
Window Size
import { useSyncExternalStore } from 'react'
// IMPORTANT: getSnapshot must return a stable reference.
// Returning { width, height } as a new object every call causes infinite re-renders.
// Solution: return a serialized string, then parse on use — or memoize the object.
let cachedSize = { width: window.innerWidth, height: window.innerHeight }
function subscribeToResize(callback: () => void) {
const handler = () => {
// Only update cache when values actually change
const w = window.innerWidth
const h = window.innerHeight
if (cachedSize.width !== w || cachedSize.height !== h) {
cachedSize = { width: w, height: h }
}
callback()
}
window.addEventListener('resize', handler)
return () => window.removeEventListener('resize', handler)
}
function getWindowSizeSnapshot() {
return cachedSize // Returns same object reference if nothing changed
}
export function useWindowSize() {
return useSyncExternalStore(
subscribeToResize,
getWindowSizeSnapshot,
() => ({ width: 1200, height: 800 }) // SSR default
)
}
// Usage
function ResponsiveLayout() {
const { width } = useWindowSize()
const isMobile = width < 768
const isTablet = width >= 768 && width < 1024
return (
<div className={isMobile ? 'mobile-layout' : isTablet ? 'tablet-layout' : 'desktop-layout'}>
{isMobile ? <MobileNav /> : <DesktopNav />}
</div>
)
}
Media Query
import { useSyncExternalStore, useCallback } from 'react'
export function useMediaQuery(query: string): boolean {
// subscribe and getSnapshot must be stable references.
// useCallback ensures they don't change between renders for the same query string.
const subscribe = useCallback((callback: () => void) => {
const mql = window.matchMedia(query)
mql.addEventListener('change', callback)
return () => mql.removeEventListener('change', callback)
}, [query])
const getSnapshot = useCallback(() => {
return window.matchMedia(query).matches
}, [query])
return useSyncExternalStore(subscribe, getSnapshot, () => false)
}
// Usage
function ThemeAwareComponent() {
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
const isRetina = useMediaQuery('(-webkit-min-device-pixel-ratio: 2)')
return (
<div data-theme={prefersDark ? 'dark' : 'light'}>
{!prefersReducedMotion && <AnimatedHero />}
<img src={isRetina ? '/logo@2x.png' : '/logo.png'} alt="Logo" />
</div>
)
}
Scroll Position
import { useSyncExternalStore } from 'react'
// Throttle scroll events for performance
let scrollY = 0
let scrollX = 0
let ticking = false
function subscribeToScroll(callback: () => void) {
const handler = () => {
if (!ticking) {
requestAnimationFrame(() => {
const newY = window.scrollY
const newX = window.scrollX
if (newY !== scrollY || newX !== scrollX) {
scrollY = newY
scrollX = newX
callback()
}
ticking = false
})
ticking = true
}
}
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}
// Return a primitive (number) — no object stability issue
export function useScrollY() {
return useSyncExternalStore(
subscribeToScroll,
() => scrollY,
() => 0
)
}
// Usage: show "back to top" button after scrolling 400px
function BackToTopButton() {
const scrollY = useScrollY()
if (scrollY < 400) return null
return (
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
style={{ position: 'fixed', bottom: 32, right: 32, borderRadius: '50%', width: 44, height: 44 }}
aria-label="Back to top"
>
↑
</button>
)
}
Building a Mini Store
// A Zustand-style store built with useSyncExternalStore
// This shows exactly what state managers do under the hood.
type Listener = () => void
function createStore<T>(initialState: T) {
let state = initialState
const listeners = new Set<Listener>()
function getState() {
return state
}
function setState(updater: Partial<T> | ((prev: T) => Partial<T>)) {
const partial = typeof updater === 'function' ? updater(state) : updater
state = { ...state, ...partial }
// Notify all subscribers
listeners.forEach(l => l())
}
function subscribe(listener: Listener) {
listeners.add(listener)
return () => listeners.delete(listener)
}
return { getState, setState, subscribe }
}
// Create a store
interface CartStore {
items: { id: string; name: string; qty: number }[]
total: number
}
const cartStore = createStore<CartStore>({ items: [], total: 0 })
// React hook binding
export function useCartStore() {
return useSyncExternalStore(cartStore.subscribe, cartStore.getState)
}
// Selector hook — only re-renders when selected value changes
export function useCartItemCount() {
return useSyncExternalStore(
cartStore.subscribe,
() => cartStore.getState().items.length
)
}
// Actions (pure functions that call setState)
export const cartActions = {
addItem(item: { id: string; name: string; price: number }) {
cartStore.setState(prev => {
const existing = prev.items.find(i => i.id === item.id)
const items = existing
? prev.items.map(i => i.id === item.id ? { ...i, qty: i.qty + 1 } : i)
: [...prev.items, { ...item, qty: 1 }]
return { items, total: items.reduce((s, i) => s + (i as any).price * i.qty, 0) }
})
},
clearCart() {
cartStore.setState({ items: [], total: 0 })
},
}
// Usage
function CartIcon() {
const count = useCartItemCount() // Only re-renders when item count changes
return <span>Cart ({count})</span>
}
How Zustand Uses It
// Zustand's useStore hook is essentially:
// useSyncExternalStore(store.subscribe, store.getState)
//
// The selector pattern:
// useStore(store, selector) wraps getState with the selector and memoizes
// the result so components only re-render when their selected slice changes.
//
// Simplified Zustand-compatible implementation:
import { useRef } from 'react'
export function useStoreWithSelector<T, S>(
store: ReturnType<typeof createStore<T>>,
selector: (state: T) => S,
): S {
const selectorRef = useRef(selector)
selectorRef.current = selector
return useSyncExternalStore(
store.subscribe,
// Wrap getState with the selector
// React re-runs this after each notification and compares with Object.is()
// If the selected slice hasn't changed, no re-render
() => selectorRef.current(store.getState()),
)
}
// Usage with selector
function CartTotal() {
// Only re-renders when total changes, not when item names change
const total = useStoreWithSelector(cartStore, s => s.total)
return <span>${total.toFixed(2)}</span>
}
// For objects returned by selectors, use a shallow equality check:
// React's Object.is compares references — a selector returning a new array
// every call will always trigger a re-render even if elements are the same.
// Solution: return primitives from selectors, or use Zustand's useShallow helper.
SSR and getServerSnapshot
// During SSR and hydration, React calls getServerSnapshot instead of getSnapshot.
// This prevents hydration mismatches when browser APIs aren't available on the server.
// WRONG — crashes on server (window is undefined):
function getSnapshot() {
return window.innerWidth // ReferenceError: window is not defined
}
// CORRECT — provide a server snapshot
export function useWindowWidth() {
return useSyncExternalStore(
subscribeToResize,
() => window.innerWidth, // Client snapshot
() => 1200, // Server snapshot — static default
)
}
// For stores that need to be hydrated from server state:
// 1. Serialize store state to HTML during SSR
// 2. Read it back in getServerSnapshot to avoid mismatch
// In your HTML template (Next.js layout.tsx):
// <script>window.__STORE_STATE__ = ${JSON.stringify(serverState)}</script>
function getServerSnapshot() {
// On the server: return a static default
if (typeof window === 'undefined') return defaultState
// On client during hydration: return the server-rendered state
return (window as any).__STORE_STATE__ ?? defaultState
}
// The shim package for older React versions:
// npm install use-sync-external-store
// import { useSyncExternalStore } from 'use-sync-external-store/shim'
// — identical API, works with React 16/17 via polyfill
When to use it: Reach for
useSyncExternalStore when you need to subscribe to anything outside React's state system — browser events, third-party libraries, WebSocket messages, RxJS streams, or any pub/sub system. For simple component-local state, useState is still the right tool. For shared app state, prefer Zustand or Jotai (which use this hook internally) over rolling your own.