External stores with useSyncExternalStore

React · Expert · 15 min

State libraries, browser APIs and shared caches all live outside React; subscribing to them correctly is required for concurrent rendering.

Some state isn't owned by React: a Redux or Zustand store, navigator.onLine, window.matchMedia, a WebSocket cache, localStorage. The naive approach (copy it into state in an effect and subscribe) causes a render with stale data first, and, worse, tearing under concurrent rendering: if the store changes while React is rendering a transition in slices, components rendered before and after the change show different values in the same commit.

`useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)` solves this:

  • subscribe(callback) registers a listener React calls whenever the store changes, and returns an unsubscribe function.
  • getSnapshot() returns the current value. React calls it during render and checks it again before committing; if the value changed mid-render, React re-renders synchronously so the whole tree sees one consistent value.
  • getServerSnapshot() returns the value to use during server rendering and hydration (where browser APIs don't exist); without it, server rendering throws.

Rules that trip people up:

  • getSnapshot must return the same value (by Object.is) when the store hasn't changed. Returning a new object or array each call ({ ...state }, items.filter(...)) causes an infinite loop. Return immutable snapshots and select or derive outside, or cache.
  • Define subscribe outside the component (or memoize it); a new function each render re-subscribes every time.
  • Updates to external stores are always synchronous: they can't be marked as transitions, so a store change that suspends shows the fallback rather than keeping old UI.

This is the primitive state libraries build on; Redux's useSelector and Zustand's hooks are implemented with it. Wrap it in a custom Hook (useOnlineStatus) and components never know where the data lives.

Example

import { useSyncExternalStore } from "react";

// A tiny store living outside React
function createStore(initial) {
  let state = initial;
  const listeners = new Set();
  return {
    getState: () => state,                      // same object until changed
    setState(update) {
      state = { ...state, ...update(state) };    // new immutable snapshot
      listeners.forEach(l => l());
    },
    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

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

function useCartCount() {
  return useSyncExternalStore(
    cartStore.subscribe,
    () => cartStore.getState().count,           // primitive: stable by Object.is
    () => 0                                     // server snapshot
  );
}

export default function CartBadge() {
  const count = useCartCount();
  return <button onClick={() => cartStore.setState(s => ({ count: s.count + 1 }))}>Cart ({count})</button>;
}

Remember

  • useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) reads external state without tearing
  • getSnapshot must return an identical value while the store is unchanged
  • Keep subscribe stable; external store updates can't be transitions

Watch out for

  • Returning a freshly built object or filtered array from getSnapshot, causing an infinite re-render loop.

Check yourself

  1. Why does getSnapshot = () => store.items.filter(i => i.visible) cause problems?
  2. What problem does useSyncExternalStore prevent that subscribing in useEffect does not?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: react.dev/reference/react/useSyncExternalStore

Builds on

Unlocks

Nothing builds on this yet.