DEV Community

Cover image for `useSyncExternalStore` — The React Hook You Didn't Know You Needed
Ankit
Ankit

Posted on

`useSyncExternalStore` — The React Hook You Didn't Know You Needed

A deep dive into React's most underrated hook — what it is, why it exists, and how to use it like a pro.


Table of Contents

  1. The Problem It Solves
  2. What Is useSyncExternalStore?
  3. API Signature
  4. A Simple First Example — Browser Tab Visibility
  5. Understanding the Three Arguments
  6. Server-Side Rendering & the getServerSnapshot
  7. Real-World Use Cases
  8. Why Not Just Use useState + useEffect?
  9. Tearing — The Core Problem This Hook Solves
  10. Building a Mini State Management Library
  11. Performance Considerations
  12. Common Mistakes & Pitfalls
  13. Compatibility & Polyfill
  14. Summary

The Problem It Solves

Before we jump into the API, let's understand why this hook was introduced in the first place.

React 18 shipped with Concurrent Mode — a new rendering engine that can pause, resume, and prioritize renders. This is fantastic for performance, but it introduced a subtle bug in how React components read from external (non-React) state sources.

Imagine you have a global store (like a Redux store, a Zustand store, or even a plain JS Map) and two components read from it. In Concurrent Mode, React might render these components at different points in time, meaning one component reads the old value while the other reads the new one. This inconsistency in the UI is called tearing, and it's visually jarring.

useSyncExternalStore was built specifically to eliminate tearing when subscribing to external stores.


What Is useSyncExternalStore?

useSyncExternalStore is a React 18 hook that lets your components safely subscribe to external data sources — anything that lives outside of React's own state management (useState, useReducer, useContext).

External sources include:

  • Custom global stores
  • Browser APIs (navigator.onLine, document.visibilityState, localStorage)
  • Third-party libraries (Zustand, Redux, Valtio, etc.)
  • Any pub-sub observable

It was formally introduced in React 18 but is also available as a standalone package (use-sync-external-store) for React 16 and 17.


API Signature

const snapshot = useSyncExternalStore(
  subscribe: (callback: () => void) => () => void,
  getSnapshot: () => Snapshot,
  getServerSnapshot?: () => Snapshot
);
Enter fullscreen mode Exit fullscreen mode

Three arguments, one return value. Clean and minimal.


A Simple First Example

Let's start with a classic: tracking whether the browser tab is visible or hidden.

import { useSyncExternalStore } from 'react';

function subscribe(callback: () => void) {
  document.addEventListener('visibilitychange', callback);
  return () => document.removeEventListener('visibilitychange', callback);
}

function getSnapshot() {
  return document.visibilityState;
}

function usePageVisibility() {
  return useSyncExternalStore(subscribe, getSnapshot);
}

// Usage in a component
function StatusBadge() {
  const visibility = usePageVisibility();

  return (
    <span>
      Tab is: <strong>{visibility === 'visible' ? '👁 Visible' : '🙈 Hidden'}</strong>
    </span>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is already cleaner and safer than the classic useEffect + useState approach. No race conditions, no tearing, and the subscribe function can be defined outside the component — meaning it doesn't get recreated on every render.


Understanding the Three Arguments

subscribe(callback)

This function is called once by React when the component mounts. It should:

  • Attach a listener to your external store.
  • Call callback whenever the store changes.
  • Return a cleanup function that removes the listener.
function subscribe(callback: () => void) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}
Enter fullscreen mode Exit fullscreen mode

Critical rule: subscribe must be a stable reference (defined outside the component or wrapped in useCallback). If you pass a new function reference on every render, React will keep re-subscribing — causing an infinite loop.

getSnapshot()

This function is called by React to read the current value from the store. React calls it:

  • During rendering to get the current value.
  • After every store update to check if the value has changed.
function getSnapshot() {
  return navigator.onLine; // returns true or false
}
Enter fullscreen mode Exit fullscreen mode

Critical rule: getSnapshot must return the same value when called multiple times if the store hasn't changed. React uses Object.is comparison. If you return a new object reference every time (return { ...state }), React will re-render infinitely.

getServerSnapshot() (optional)

This is for Server-Side Rendering (SSR). Since browser APIs aren't available on the server, you need to provide a fallback value. If omitted and you use SSR, React will throw an error.

function getServerSnapshot() {
  return true; // assume online on the server
}
Enter fullscreen mode Exit fullscreen mode

Server-Side Rendering & the getServerSnapshot

If your app uses SSR (Next.js, Remix, etc.), the getServerSnapshot argument is not optional — it's essential.

Here's the full pattern for a safe, SSR-compatible hook:

import { useSyncExternalStore } from 'react';

function useNetworkStatus() {
  return useSyncExternalStore(
    (callback) => {
      window.addEventListener('online', callback);
      window.addEventListener('offline', callback);
      return () => {
        window.removeEventListener('online', callback);
        window.removeEventListener('offline', callback);
      };
    },
    () => navigator.onLine,     // client snapshot
    () => true                  // server snapshot (assume online)
  );
}
Enter fullscreen mode Exit fullscreen mode

On the server, React uses getServerSnapshot. On the client, it uses getSnapshot. This ensures hydration matches.


Real-World Use Cases

1. Subscribing to a Custom Store

Let's build a simple reactive counter store and consume it with useSyncExternalStore.

// store.ts
type Listener = () => void;

let count = 0;
const listeners = new Set<Listener>();

export const counterStore = {
  getSnapshot: () => count,
  subscribe: (listener: Listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  },
  increment: () => {
    count++;
    listeners.forEach((l) => l());
  },
  decrement: () => {
    count--;
    listeners.forEach((l) => l());
  },
};
Enter fullscreen mode Exit fullscreen mode
// Counter.tsx
import { useSyncExternalStore } from 'react';
import { counterStore } from './store';

function Counter() {
  const count = useSyncExternalStore(
    counterStore.subscribe,
    counterStore.getSnapshot
  );

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={counterStore.increment}>+</button>
      <button onClick={counterStore.decrement}>-</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Any number of Counter components rendered anywhere in your tree will always see the same value — no tearing, no stale reads.


2. Reading from localStorage Reactively

localStorage is a classic external store. Here's a hook that keeps a value in sync:

import { useSyncExternalStore, useCallback } from 'react';

function useLocalStorage<T>(key: string, initialValue: T) {
  const subscribe = useCallback(
    (callback: () => void) => {
      window.addEventListener('storage', callback);
      return () => window.removeEventListener('storage', callback);
    },
    []
  );

  const getSnapshot = useCallback((): T => {
    const item = localStorage.getItem(key);
    return item ? JSON.parse(item) : initialValue;
  }, [key, initialValue]);

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

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

  const setValue = useCallback(
    (newValue: T) => {
      localStorage.setItem(key, JSON.stringify(newValue));
      // Dispatch a storage event manually for same-tab updates
      window.dispatchEvent(new Event('storage'));
    },
    [key]
  );

  return [value, setValue] as const;
}
Enter fullscreen mode Exit fullscreen mode

Note: The native storage event only fires across tabs, not within the same tab. The manual dispatchEvent handles same-tab updates.


3. Syncing with a Third-Party Library

Say you're using a third-party pub-sub or observable. Here's how to wrap it:

import { useSyncExternalStore } from 'react';
import { someExternalEmitter } from 'some-library';

function useExternalData() {
  return useSyncExternalStore(
    (callback) => {
      const unsubscribe = someExternalEmitter.on('change', callback);
      return unsubscribe;
    },
    () => someExternalEmitter.getCurrentValue()
  );
}
Enter fullscreen mode Exit fullscreen mode

This is the pattern that state management libraries like Zustand and Redux use internally.


4. Network Online/Offline Status

import { useSyncExternalStore } from 'react';

const subscribe = (callback: () => void) => {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
};

export function useIsOnline() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine,
    () => true
  );
}

// In a component
function NetworkBanner() {
  const isOnline = useIsOnline();
  if (isOnline) return null;

  return (
    <div style={{ background: 'red', color: 'white', padding: '8px', textAlign: 'center' }}>
      You are offline. Some features may not be available.
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why Not Just Use useState + useEffect?

This is the most common question. You've probably written this pattern before:

// The classic pattern — looks fine, but has issues
function useIsOnline() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return isOnline;
}
Enter fullscreen mode Exit fullscreen mode

This seems reasonable. But it has three subtle problems:

Problem 1 — The initial value might be stale. By the time the effect runs (after paint), the network status could have already changed. There's a window where the component renders with a stale value.

Problem 2 — Tearing in Concurrent Mode. In React 18 with Concurrent rendering, two components using this hook could read different values of isOnline if a network change happens mid-render.

Problem 3 — No SSR safety. Calling navigator.onLine inside useState initializer will throw during SSR because navigator doesn't exist on the server.

useSyncExternalStore solves all three of these at the framework level.


Tearing — The Core Problem This Hook Solves

Let's make tearing concrete. Imagine two components read a global variable externalCount:

React starts rendering Component A → reads externalCount = 5
...concurrent pause happens...
externalCount changes to 6
...rendering resumes...
React renders Component B → reads externalCount = 6
Enter fullscreen mode Exit fullscreen mode

Now your UI shows two different values for the same piece of data. That's tearing. It's a race condition baked into the rendering model.

useSyncExternalStore tells React: "When you render any component subscribed to this store, make sure all of them see the same snapshot." React guarantees this by synchronizing the render.

This is what the word "Sync" in the hook name means — it forces React to synchronize with the external store, even in Concurrent Mode.


Building a Mini State Management Library

Let's go further and use useSyncExternalStore to build a fully functional, type-safe, minimal state manager in ~40 lines:

// createStore.ts
import { useSyncExternalStore } from 'react';

type SetState<T> = (updater: Partial<T> | ((prev: T) => Partial<T>)) => void;
type Selector<T, R> = (state: T) => R;

export function createStore<T extends object>(initialState: T) {
  let state = initialState;
  const listeners = new Set<() => void>();

  const getState = () => state;

  const setState: SetState<T> = (updater) => {
    const partial =
      typeof updater === 'function' ? updater(state) : updater;
    state = { ...state, ...partial };
    listeners.forEach((l) => l());
  };

  const subscribe = (callback: () => void) => {
    listeners.add(callback);
    return () => listeners.delete(callback);
  };

  function useStore(): T;
  function useStore<R>(selector: Selector<T, R>): R;
  function useStore<R>(selector?: Selector<T, R>): T | R {
    return useSyncExternalStore(
      subscribe,
      selector ? () => selector(state) : getState
    );
  }

  return { getState, setState, subscribe, useStore };
}
Enter fullscreen mode Exit fullscreen mode
// Usage
const { useStore, setState } = createStore({
  user: null as string | null,
  theme: 'light' as 'light' | 'dark',
  count: 0,
});

function ThemeToggle() {
  const theme = useStore((s) => s.theme); // only re-renders when theme changes

  return (
    <button onClick={() => setState({ theme: theme === 'light' ? 'dark' : 'light' })}>
      Switch to {theme === 'light' ? 'Dark' : 'Light'} Mode
    </button>
  );
}

function Counter() {
  const count = useStore((s) => s.count); // only re-renders when count changes

  return (
    <button onClick={() => setState((s) => ({ count: s.count + 1 }))}>
      Count: {count}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

That's a fully working, selector-based, tear-free state manager in ~40 lines of code. This is essentially what Zustand does under the hood.


Performance Considerations

  • Define subscribe outside the component. Defining it inside causes a new function reference on every render, which triggers React to re-subscribe. Move it to module scope or wrap it in useCallback.

  • getSnapshot must be pure and stable. React calls it frequently. Keep it fast — just return a value, don't compute expensive things inside it.

  • Use selectors for large stores. Instead of subscribing to the entire store object, narrow down with a selector. React uses Object.is to compare snapshots, so if the selected slice hasn't changed, no re-render happens.

  • Avoid returning new objects from getSnapshot. This is the most common performance bug:

// ❌ BAD — returns a new object reference every call
const getSnapshot = () => ({ ...store.state });

// ✅ GOOD — returns a stable primitive or the same reference
const getSnapshot = () => store.state.count;
Enter fullscreen mode Exit fullscreen mode

Common Mistakes & Pitfalls

Mistake Why It's a Problem Fix
Defining subscribe inside the component New reference every render → infinite re-subscriptions Move to module scope or use useCallback
Returning new objects from getSnapshot Object.is always false → infinite re-renders Return primitives or stable references
Forgetting getServerSnapshot in SSR apps React throws a hydration error Always provide it for any browser API access
Not returning the cleanup function from subscribe Memory leak — listeners pile up indefinitely Always return () => removeListener(...)
Using getSnapshot to compute derived state Can cause subtle bugs with stale closures Compute derived values outside the hook

Compatibility & Polyfill

useSyncExternalStore is available natively in React 18+.

For React 16.8–17, use the official shim:

npm install use-sync-external-store
Enter fullscreen mode Exit fullscreen mode
// For React 16/17
import { useSyncExternalStore } from 'use-sync-external-store/shim';

// With server snapshot support
import { useSyncExternalStore } from 'use-sync-external-store/shim/with-selector';
Enter fullscreen mode Exit fullscreen mode

The shim is maintained by the React team and is fully compatible.


Summary

useSyncExternalStore is the right tool whenever your component needs to read from anything that's not React state. It's not a niche hook for library authors only — it's a fundamental primitive for anyone doing:

  • Custom hooks that read from browser APIs
  • Integrating third-party stores or observables
  • Building micro state management solutions
  • Avoiding tearing in concurrent-mode apps

Here's the mental checklist: if you find yourself writing useEffect(() => { externalThing.subscribe(...) }, []) to keep local state in sync with something outside React — reach for useSyncExternalStore instead. It's safer, cleaner, and React 18-compatible from the ground up.


Happy building. Keep your stores external and your snapshots stable.

Top comments (0)