Skip to content

useSyncExternalStore

intermediate12 min read

The Problem: Tearing in Concurrent React

Here's a subtle problem that didn't exist before React 18. Concurrent rendering can pause a render mid-way and resume later. If your component reads from an external mutable source (not React state), different parts of the same render might see different values — a bug called tearing.

// UNSAFE in concurrent React:
let externalCounter = 0;

function Counter() {
  // This reads a mutable external value during render.
  // In concurrent mode, React may pause between rendering
  // Child1 and Child2. If externalCounter changes during
  // the pause, Child1 shows 5 and Child2 shows 6.
  return (
    <div>
      <Child1 value={externalCounter} />
      <Child2 value={externalCounter} />
    </div>
  );
}

useSyncExternalStore solves this by forcing synchronous reads from external stores during render, preventing tearing.

Mental Model

Think of concurrent rendering as reading pages of a newspaper while someone is updating articles. Without synchronization, you might read page 1 with the old headline and page 3 with the updated headline — the newspaper is torn. useSyncExternalStore is like taking a snapshot of the entire newspaper before you start reading. Even if the newspaper updates while you read, you see a consistent version.

The API

const snapshot = useSyncExternalStore(
  subscribe,      // Function to subscribe to store changes
  getSnapshot,    // Function to read the current value
  getServerSnapshot // Optional: Function to read value during SSR
);

Three requirements:

  1. subscribe(callback): registers a listener, returns an unsubscribe function
  2. getSnapshot(): returns the current value. Must return the same reference if nothing changed
  3. getServerSnapshot(): returns the value for server rendering

Basic Example: Window Width

function useWindowWidth() {
  return useSyncExternalStore(
    // subscribe: listen for resize events
    (callback) => {
      window.addEventListener('resize', callback);
      return () => window.removeEventListener('resize', callback);
    },
    // getSnapshot: read current width
    () => window.innerWidth,
    // getServerSnapshot: default for SSR
    () => 1024
  );
}

function ResponsiveLayout() {
  const width = useWindowWidth();
  return width > 768 ? <DesktopLayout /> : <MobileLayout />;
}

Subscribing to External Stores

Browser Online Status

function useOnlineStatus() {
  return useSyncExternalStore(
    (callback) => {
      window.addEventListener('online', callback);
      window.addEventListener('offline', callback);
      return () => {
        window.removeEventListener('online', callback);
        window.removeEventListener('offline', callback);
      };
    },
    () => navigator.onLine,
    () => true // Assume online during SSR
  );
}

Custom External Store

function createStore(initialState) {
  let state = initialState;
  const listeners = new Set();

  return {
    getState: () => state,
    setState: (newState) => {
      state = typeof newState === 'function' ? newState(state) : newState;
      listeners.forEach(listener => listener());
    },
    subscribe: (listener) => {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

const counterStore = createStore({ count: 0 });

function CounterDisplay() {
  const { count } = useSyncExternalStore(
    counterStore.subscribe,
    counterStore.getState,
    counterStore.getState
  );

  return <p>Count: {count}</p>;
}

function IncrementButton() {
  return (
    <button onClick={() => counterStore.setState(s => ({ count: s.count + 1 }))}>
      Increment
    </button>
  );
}
How Zustand uses useSyncExternalStore

Zustand, one of the most popular React state managers, uses useSyncExternalStore internally. When you call useStore(selector), Zustand:

  1. Subscribes to the store using useSyncExternalStore
  2. Uses the selector as part of getSnapshot to extract the relevant slice
  3. Compares snapshots with Object.is to prevent unnecessary re-renders

This is why Zustand is concurrent-safe out of the box — it delegates the hard synchronization work to React.

The getSnapshot Contract

This is the part that trips people up. getSnapshot has a strict requirement: it must return the same reference if nothing has changed:

// WRONG — new object every call
const getSnapshot = () => ({
  count: store.count,
  name: store.name,
});
// This creates an infinite loop! Each call returns a new object,
// React thinks the store changed, re-renders, calls getSnapshot again...

// CORRECT — return the store object directly
const getSnapshot = () => store.getState();
// Same reference if nothing changed

// CORRECT — memoize selector results
function useStoreSelector(selector) {
  const prevRef = useRef();
  const getSnapshot = useCallback(() => {
    const next = selector(store.getState());
    if (Object.is(prevRef.current, next)) {
      return prevRef.current;
    }
    prevRef.current = next;
    return next;
  }, [selector]);

  return useSyncExternalStore(store.subscribe, getSnapshot);
}
Common Trap

If getSnapshot returns a new object reference on every call, useSyncExternalStore enters an infinite re-render loop. React calls getSnapshot, sees a "new" value (different reference), re-renders, calls getSnapshot again, gets another "new" value... Always return the same reference when the data has not changed.

Production Scenario: Media Query Hook

function useMediaQuery(query) {
  const subscribe = useCallback(
    (callback) => {
      const mql = window.matchMedia(query);
      mql.addEventListener('change', callback);
      return () => mql.removeEventListener('change', callback);
    },
    [query]
  );

  const getSnapshot = useCallback(
    () => window.matchMedia(query).matches,
    [query]
  );

  return useSyncExternalStore(
    subscribe,
    getSnapshot,
    () => false // Default for SSR
  );
}

function Navigation() {
  const isMobile = useMediaQuery('(max-width: 768px)');
  return isMobile ? <HamburgerMenu /> : <FullNavBar />;
}
Execution Trace
Mount
useSyncExternalStore(subscribe, getSnapshot)
React subscribes to the store
Read
getSnapshot() returns current value
Synchronous read during render — consistent
External change
Store updates, calls subscriber callback
subscribe's callback is triggered
Re-check
React calls getSnapshot() again
Compares with previous snapshot
Re-render
If snapshot changed → component re-renders
If same reference → no re-render
Unmount
React calls the unsubscribe function
Cleanup from subscribe's return value
What developers doWhat they should do
Creating new objects in getSnapshot: () => ({ ...store })
New references on every call cause infinite re-render loops. useSyncExternalStore compares with Object.is.
Return the same reference when data has not changed
Using useState + useEffect to subscribe to external stores
The useState + useEffect pattern can tear in concurrent React. useSyncExternalStore forces synchronous reads that prevent this.
Use useSyncExternalStore — it handles concurrent mode tearing
Forgetting getServerSnapshot for SSR
Without getServerSnapshot, the hook throws during SSR because window/navigator/etc. are not available.
Always provide the third argument when using with Next.js or SSR frameworks
Putting subscribe inline without memoization: useSyncExternalStore((cb) => {...}, ...)
A new subscribe function every render causes React to unsubscribe and resubscribe on every render.
Memoize subscribe with useCallback or define it outside the component
Quiz
What problem does useSyncExternalStore solve that useState + useEffect cannot?
Quiz
What happens if getSnapshot returns a new object reference every time?
Quiz
Why must the subscribe function be stable (not recreated every render)?
Key Rules
  1. 1useSyncExternalStore prevents tearing in concurrent React by forcing synchronous reads
  2. 2getSnapshot must return the same reference when data has not changed — or you get infinite loops
  3. 3subscribe must be stable (memoized or static) to avoid unsubscribe/resubscribe churn
  4. 4Always provide getServerSnapshot for SSR compatibility
  5. 5Use this for browser APIs (resize, online, media queries) and external stores (Zustand, custom stores)

Challenge: Build a Local Storage Hook

Challenge: Synchronized Local Storage

// Build a hook that syncs state with localStorage,
// using useSyncExternalStore so it works across browser tabs.
// When one tab updates the value, other tabs should reflect the change.

function useLocalStorage(key, initialValue) {
  // Your implementation here
  // Hint: Use the 'storage' event for cross-tab sync
}

// Usage:
function App() {
  const [name, setName] = useLocalStorage('username', 'Anonymous');
  return <input value=`{name}` onChange=`{e => setName(e.target.value)}` />;
}
Show Answer
function useLocalStorage(key, initialValue) {
  const getSnapshot = useCallback(() => {
    const stored = localStorage.getItem(key);
    return stored !== null ? JSON.parse(stored) : initialValue;
  }, [key, initialValue]);

  const subscribe = useCallback((callback) => {
    const handler = (e) => {
      if (e.key === key || e.key === null) {
        callback();
      }
    };
    window.addEventListener('storage', handler);
    return () => window.removeEventListener('storage', handler);
  }, [key]);

  const getServerSnapshot = useCallback(() => initialValue, [initialValue]);

  const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

  const setValue = useCallback((newValue) => {
    const resolved = typeof newValue === 'function' ? newValue(getSnapshot()) : newValue;
    localStorage.setItem(key, JSON.stringify(resolved));
    // Dispatch storage event for same-tab notification
    // (storage event only fires in OTHER tabs by default)
    window.dispatchEvent(new StorageEvent('storage', { key }));
  }, [key, getSnapshot]);

  return [value, setValue];
}

Key details:

  • storage event fires in OTHER tabs when localStorage changes
  • We manually dispatch a StorageEvent for same-tab updates
  • getSnapshot reads from localStorage on every call (it returns a primitive or the same parsed value)
  • subscribe listens to the storage event and notifies useSyncExternalStore
  • Works across browser tabs — change in tab A updates tab B