DEV_NET_CORE
GET_STARTED
ReactState management, performance, and rendering optimization

useSyncExternalStore and subscribing to external state

Overview

useSyncExternalStore is a React hook for reading and subscribing to state that lives outside React. It gives React a consistent way to subscribe to an external source, read a snapshot during render, and update the component when that source changes.

External state can come from many places: a custom store, Redux-like store, browser API, WebSocket connection, media query, online/offline status, local storage event, or a third-party state library. The key requirement is that React needs a safe subscription contract.

This topic matters because subscribing to external state with a plain useEffect and useState can create subtle bugs in concurrent rendering, server rendering, hydration, and stale snapshot scenarios. useSyncExternalStore is the official low-level hook for building external store integrations.

For interviews, this topic tests whether a candidate understands the difference between React state and external state, how subscriptions work, why snapshots must be stable, and when application developers should use this hook directly versus relying on a state library.

Core Concepts

What Counts as External State

External state is state not owned by React's component state system.

Examples:

  • Redux store.
  • Zustand-like custom store.
  • Browser online/offline status.
  • window.matchMedia result.
  • WebSocket-backed presence store.
  • Shared worker state.
  • Local storage changes across tabs.
  • A custom event emitter.
  • A map library or editor model.

React can render from external state, but it needs to know when the state changes and how to read a consistent snapshot.

The useSyncExternalStore API

The hook shape is:

Code
const snapshot = useSyncExternalStore(
  subscribe,
  getSnapshot,
  getServerSnapshot,
);

Arguments:

  • subscribe: registers a callback and returns an unsubscribe function.
  • getSnapshot: returns the current client-side value.
  • getServerSnapshot: optional function for server rendering and hydration.

Basic example:

Code
function useOnlineStatus() {
  return useSyncExternalStore(
    subscribeToOnlineStatus,
    () => navigator.onLine,
    () => true,
  );
}

function subscribeToOnlineStatus(callback: () => void) {
  window.addEventListener("online", callback);
  window.addEventListener("offline", callback);

  return () => {
    window.removeEventListener("online", callback);
    window.removeEventListener("offline", callback);
  };
}

The component can now render based on a browser value that lives outside React.

subscribe

subscribe connects React to the external source.

Code
function subscribe(callback: () => void) {
  store.addListener(callback);
  return () => store.removeListener(callback);
}

Requirements:

  • It must call the callback when the external store changes.
  • It must return a cleanup function.
  • It should be stable when possible.
  • It should not update React state directly.
  • It should not perform rendering logic.

If subscribe is declared inside a component and changes on every render, React may resubscribe more often than needed.

getSnapshot

getSnapshot returns the current value from the external source.

Code
function getSnapshot() {
  return store.getState();
}

Important rule: repeated calls to getSnapshot must return the same value if the store has not changed.

Bad:

Code
function getSnapshot() {
  return { ...store.getState() };
}

This returns a new object every time, so React may think the snapshot changed even when the store did not.

Better:

Code
function getSnapshot() {
  return store.getState();
}

The store should maintain stable immutable snapshots or cache derived snapshots.

Simple Custom Store

Example external store:

Code
type CounterStore = {
  getSnapshot: () => number;
  subscribe: (callback: () => void) => () => void;
  increment: () => void;
};

function createCounterStore(): CounterStore {
  let count = 0;
  const listeners = new Set<() => void>();

  return {
    getSnapshot: () => count,
    subscribe: (callback) => {
      listeners.add(callback);
      return () => listeners.delete(callback);
    },
    increment: () => {
      count += 1;
      listeners.forEach((listener) => listener());
    },
  };
}

export const counterStore = createCounterStore();

Hook:

Code
function useCounterCount() {
  return useSyncExternalStore(
    counterStore.subscribe,
    counterStore.getSnapshot,
    counterStore.getSnapshot,
  );
}

Component:

Code
function Counter() {
  const count = useCounterCount();

  return (
    <button onClick={counterStore.increment}>
      Count: {count}
    </button>
  );
}

The store lives outside React. React subscribes and rerenders when the snapshot changes.

Why Not Just useEffect?

A basic subscription can be written with useEffect:

Code
useEffect(() => {
  return store.subscribe(() => {
    setState(store.getState());
  });
}, []);

This works for simple cases, but it has downsides:

  • The first render may use stale or duplicated initial state.
  • It is easier to mismatch render-time values and subscription updates.
  • It is not the official contract for external stores.
  • It is harder to support server rendering and hydration safely.
  • It can be less robust with concurrent rendering.

useSyncExternalStore tells React exactly how to read the current snapshot during render and how to subscribe to future changes.

Snapshot Stability

Snapshot stability is the most common pitfall.

If getSnapshot returns a new object each time, React sees constant change.

Bad:

Code
function getSnapshot() {
  return {
    todos: store.todos,
    filter: store.filter,
  };
}

Better:

Code
let snapshot = {
  todos: store.todos,
  filter: store.filter,
};

function updateStore(nextTodos: Todo[], nextFilter: string) {
  store.todos = nextTodos;
  store.filter = nextFilter;
  snapshot = {
    todos: nextTodos,
    filter: nextFilter,
  };
  emitChange();
}

function getSnapshot() {
  return snapshot;
}

For mutable stores, cache immutable snapshots so unchanged state returns the same reference.

Selectors

Many components only need part of a store.

Conceptual selector:

Code
function useUserName() {
  const state = useSyncExternalStore(
    userStore.subscribe,
    userStore.getSnapshot,
    userStore.getSnapshot,
  );

  return state.user.name;
}

This works, but the component may rerender whenever the full snapshot changes, even if user.name is the same. Mature state libraries usually add selector support so components subscribe to slices efficiently.

Selector concerns:

  • Selected value equality.
  • Stable selected references.
  • Avoiding broad rerenders.
  • Memoized derived data.
  • Store update granularity.

This is one reason teams often use a library instead of writing a custom store by hand.

Server Rendering and getServerSnapshot

getServerSnapshot is used during server rendering and hydration.

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

It should return the same initial value on the server and during client hydration.

Example:

Code
function getServerSnapshot() {
  return initialStoreStateFromHtml;
}

If the server snapshot and first client snapshot differ, hydration mismatches can happen. Browser-only state such as navigator.onLine often uses a conservative server fallback.

Browser API Example: Media Query

useSyncExternalStore can wrap browser APIs.

Code
function subscribeToMediaQuery(query: string, callback: () => void) {
  const mediaQuery = window.matchMedia(query);
  mediaQuery.addEventListener("change", callback);

  return () => mediaQuery.removeEventListener("change", callback);
}

function useMediaQuery(query: string) {
  return useSyncExternalStore(
    (callback) => subscribeToMediaQuery(query, callback),
    () => window.matchMedia(query).matches,
    () => false,
  );
}

This hook exposes external browser state as a React-readable value.

Local Storage and Cross-Tab State

The browser storage event can notify other tabs when localStorage changes.

Code
function subscribeToStorage(callback: () => void) {
  window.addEventListener("storage", callback);
  return () => window.removeEventListener("storage", callback);
}

function getThemeSnapshot() {
  return localStorage.getItem("theme") ?? "light";
}

function useStoredTheme() {
  return useSyncExternalStore(
    subscribeToStorage,
    getThemeSnapshot,
    () => "light",
  );
}

Note that the storage event fires in other documents, not usually in the same tab that made the change. A robust store wrapper should also notify same-tab subscribers when it writes.

Immutable Updates

External stores should notify subscribers only after a meaningful change and should expose stable snapshots.

Bad mutable pattern:

Code
state.user.name = "New name";
emitChange();

If getSnapshot returns the same state reference, React may not detect a changed snapshot correctly.

Better:

Code
state = {
  ...state,
  user: {
    ...state.user,
    name: "New name",
  },
};
emitChange();

Immutable updates make snapshot identity meaningful.

Tearing

Tearing means different parts of the UI observe different versions of external state during the same render. useSyncExternalStore exists to help React coordinate external subscriptions with rendering so components read consistent snapshots.

Application developers do not usually need to implement complex tearing logic themselves, but they should understand the contract:

  • getSnapshot must be pure.
  • Snapshots must be stable.
  • Subscriptions must notify on changes.
  • The store should not mutate state behind React's back without notification.

If the contract is broken, UI consistency breaks.

When to Use It Directly

Use useSyncExternalStore directly when building:

  • A custom external store.
  • A wrapper around a browser API.
  • A library integration.
  • A bridge to a non-React state source.
  • A low-level hook used across an app.

Do not reach for it for ordinary component state. Use useState, useReducer, context, or a state library first.

Most application developers use useSyncExternalStore indirectly through libraries.

Common Mistakes

Common mistakes include:

  • Returning a new object from getSnapshot every call.
  • Mutating store state without changing snapshot identity.
  • Forgetting to unsubscribe.
  • Creating subscribe inside render without stability.
  • Using useSyncExternalStore for local component state.
  • Omitting getServerSnapshot in SSR scenarios that need it.
  • Returning different server and hydration snapshots.
  • Building a complex store without selector/equality support.
  • Not notifying subscribers after store changes.

Best Practices

Best practices include:

  • Keep getSnapshot pure and stable.
  • Return the same snapshot reference when state has not changed.
  • Notify subscribers after every meaningful store change.
  • Return an unsubscribe function from subscribe.
  • Use immutable snapshots or cache derived snapshots.
  • Provide getServerSnapshot for server-rendered apps.
  • Wrap external stores in custom hooks.
  • Prefer established libraries for complex app-wide stores.
  • Add selector/equality support if many consumers need different slices.
  • Test subscription, unsubscribe, unchanged snapshots, and SSR fallback behavior.

Interview Practice

PreviousSuspense, transitions, and rendering priority conceptsNext UpDebugging rendering, hydration, and interaction issues