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.

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.