# React `useSyncExternalStore` in 2026: The Hook Every State Library Uses and Why You Should Understand It

> Source: <https://dev.to/jsmanifest/react-usesyncexternalstore-in-2026-the-hook-every-state-library-uses-and-why-you-should-4fmo>
> Published: 2026-08-31 17:53:12+00:00

`useSyncExternalStore`

in 2026: The Hook Every State Library Uses and Why You Should Understand It

This article was written with the assistance of AI, under human supervision and review.

Most React state management confusion stems from teams treating external stores as if they're native React state. The assumption breaks in React 18's concurrent rendering model, where a component can read from a store twice during a single render and receive different values. This inconsistency—called tearing—corrupts UI state in ways that are expensive to debug and embarrassing to ship.

The pattern that eliminates tearing is `useSyncExternalStore`

, the hook that every production state library now uses under the hood. Developers dismiss it as "library internals" without realizing it's the foundation that makes Zustand, Jotai, and Redux work correctly in concurrent mode. When you understand this hook, you understand why your state library behaves the way it does—and when you need to build a custom store, you know exactly how to integrate it safely.

React 18 introduced time-slicing and transitions that allow renders to pause and resume. A store outside React's control can change during that pause. Without `useSyncExternalStore`

, the resumed render sees new data while sibling components still reference old snapshots. The UI enters an inconsistent state where a product list shows five items but the cart count displays four.

`useSyncExternalStore`

solves this by forcing React to commit any in-progress render when the external store changes. The guarantee is simple: every component in a render tree sees the same snapshot value, even when renders interleave or suspend. This synchronization is what "sync" means in the hook's name—it's not about synchronous code execution, it's about snapshot consistency.

`useSyncExternalStore`

prevents tearing by ensuring all components in a concurrent render read the same snapshot value from an external store.`subscribe`

function that registers a listener, a `getSnapshot`

function that returns immutable data, and optionally a `getServerSnapshot`

for SSR hydration.`useSyncExternalStore`

internally—understanding it reveals why their APIs enforce certain patterns like immutable updates.`useSyncExternalStore`

for any data source outside React's control (browser APIs, WebSockets, shared workers); use Context for component-tree-scoped state that doesn't need external sync.`useSyncExternalStore`

accepts three arguments, and their interaction determines whether the integration succeeds or fails. The first argument is `subscribe`

, a function that takes a callback and registers it with the external store. When the store changes, it must invoke all registered callbacks. The second argument is `getSnapshot`

, which returns the current store value. React calls this function during render and compares the returned reference to detect changes. The optional third argument is `getServerSnapshot`

, which provides the initial value during server-side rendering when the external store doesn't exist yet.

The contract between these functions is rigid. The `subscribe`

function must return an unsubscribe function that removes the callback when the component unmounts. React expects this cleanup to prevent memory leaks when components re-render or unmount. The failure mode here is subtle: if `subscribe`

returns `undefined`

or a non-function, React silently skips cleanup and the callback continues firing after the component is gone, updating state that no longer exists.

The `getSnapshot`

function must return the same reference for equal values. React uses `Object.is`

comparison to determine if a re-render is necessary. Returning a new object on every call—even if the contents are identical—triggers infinite render loops. This is why store implementations typically cache snapshots and only create new references when the underlying data actually changes. The discipline required here is stricter than `useMemo`

or `useCallback`

because React cannot fix violations for you.

The `getServerSnapshot`

argument addresses the timing mismatch between server and client. On the server, external stores like `localStorage`

or `WebSocket`

connections don't exist. React needs a value to render the initial HTML. When the client hydrates, it must use the same initial value to match the server-rendered markup, then switch to the live store. Omitting `getServerSnapshot`

when targeting SSR causes hydration mismatches that manifest as content flashes or suppressed event handlers.

The clearest way to understand `useSyncExternalStore`

is to build a custom hook that wraps browser storage. The requirement is simple: when `localStorage`

changes in one tab, all subscribed components across all tabs must re-render with the new value. This cross-tab synchronization is exactly the kind of external data source that Context cannot handle and where custom event listeners fail without careful subscription management.

``` js
function createStorageStore<T>(key: string, initialValue: T) {
  let currentValue = initialValue;
  const listeners = new Set<() => void>();

  // Load initial value from localStorage
  if (typeof window !== 'undefined') {
    const stored = localStorage.getItem(key);
    if (stored !== null) {
      try {
        currentValue = JSON.parse(stored);
      } catch {
        // If parse fails, use initialValue
      }
    }
  }

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

    // Listen to storage events from other tabs
    const handleStorage = (e: StorageEvent) => {
      if (e.key === key) {
        const newValue = e.newValue ? JSON.parse(e.newValue) : initialValue;
        currentValue = newValue;
        listeners.forEach(listener => listener());
      }
    };

    window.addEventListener('storage', handleStorage);

    return () => {
      listeners.delete(callback);
      window.removeEventListener('storage', handleStorage);
    };
  };

  const getSnapshot = () => currentValue;

  const getServerSnapshot = () => initialValue;

  const setState = (nextValue: T | ((prev: T) => T)) => {
    const newValue = typeof nextValue === 'function' 
      ? (nextValue as (prev: T) => T)(currentValue)
      : nextValue;

    currentValue = newValue;
    localStorage.setItem(key, JSON.stringify(newValue));
    listeners.forEach(listener => listener());
  };

  return { subscribe, getSnapshot, getServerSnapshot, setState };
}

function useLocalStorage<T>(key: string, initialValue: T) {
  const store = React.useMemo(
    () => createStorageStore(key, initialValue),
    [key]
  );

  const value = React.useSyncExternalStore(
    store.subscribe,
    store.getSnapshot,
    store.getServerSnapshot
  );

  return [value, store.setState] as const;
}

// Usage
function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Current theme: {theme}
    </button>
  );
}
```

The implementation maintains a `listeners`

Set to track subscribed components. When `setState`

is called, it updates both the in-memory `currentValue`

and `localStorage`

, then notifies all listeners. This dual update is critical: updating only `localStorage`

would miss components in the same tab, while updating only memory would miss cross-tab synchronization.

The `storage`

event listener handles changes from other tabs. Browser storage events only fire in tabs that did not trigger the change. When another tab calls `setTheme`

, the event fires in this tab, updating `currentValue`

and notifying local listeners. Without this listener, the hook would only work within a single tab—a common mistake when developers test in one browser window.

The `useMemo`

call ensures `createStorageStore`

runs once per unique key. Without it, every render would create a new store instance with a new `subscribe`

function, and React would treat that as a subscription change, triggering unsubscribe-then-resubscribe on every render. This creates a memory leak where old listeners accumulate because the cleanup function references a stale `listeners`

Set.

State libraries adopted `useSyncExternalStore`

to eliminate tearing without forcing developers to understand the hook directly. The abstraction works because these libraries control the store implementation and can guarantee the subscription and snapshot contracts. When developers call `useStore`

or `useAtom`

, they're indirectly invoking `useSyncExternalStore`

with library-managed functions.

Zustand exposes a `subscribe`

method on the store object that accepts a listener function. Internally, it maintains a Set of listeners identical to the storage example above. When developers call `set`

to update state, Zustand updates the internal store object, then invokes every listener. The `useStore`

hook wraps this with `useSyncExternalStore`

, passing the store's `subscribe`

method directly and a `getSnapshot`

function that returns the current state reference.

Jotai takes a different approach because its atoms are independent units rather than a single store. Each atom has its own subscription mechanism, but the library maintains a global WeakMap that tracks which atoms are mounted and which components subscribe to each. When an atom's value changes, Jotai looks up all subscribed components in the WeakMap and notifies them. The `useAtom`

hook calls `useSyncExternalStore`

with an atom-specific subscribe function that registers the component in this WeakMap.

Redux Toolkit's `useSelector`

hook migrated from a custom subscription system to `useSyncExternalStore`

in version 8. The store's `subscribe`

method registers listeners that fire on every action dispatch. The `getSnapshot`

function runs the selector against the current state and returns the result. Redux compares the selector output with `Object.is`

, so selectors that return new objects on every call cause the same infinite loop problem. This is why Redux documentation emphasizes memoized selectors—it's a requirement of `useSyncExternalStore`

, not a Redux-specific optimization.

The common pattern across all three libraries is that they handle subscription stability and snapshot immutability internally, so developers never write a `subscribe`

function by hand. This abstraction is valuable, but it also means teams adopt these libraries without understanding why certain patterns—like immutable updates in Zustand or memoized selectors in Redux—are mandatory rather than suggested.

Understanding the hook's mechanics makes building a custom store straightforward when the requirements don't fit existing libraries. The goal is a global store with actions, TypeScript safety, and automatic re-renders—essentially Zustand's core features in minimal code.

``` js
type Listener = () => void;
type SetState<T> = (partial: Partial<T> | ((state: T) => Partial<T>)) => void;

interface StoreApi<T> {
  getState: () => T;
  setState: SetState<T>;
  subscribe: (listener: Listener) => () => void;
}

function createStore<T extends Record<string, unknown>>(
  initialState: T
): StoreApi<T> {
  let state = initialState;
  const listeners = new Set<Listener>();

  const getState = () => state;

  const setState: SetState<T> = (partial) => {
    const nextState = typeof partial === 'function' 
      ? partial(state) 
      : partial;

    state = { ...state, ...nextState };
    listeners.forEach(listener => listener());
  };

  const subscribe = (listener: Listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  };

  return { getState, setState, subscribe };
}

function createUseStore<T extends Record<string, unknown>>(
  store: StoreApi<T>
) {
  return function useStore(): T;
  return function useStore<U>(selector: (state: T) => U): U;
  return function useStore<U>(selector?: (state: T) => U) {
    const selectedState = React.useSyncExternalStore(
      store.subscribe,
      () => selector ? selector(store.getState()) : store.getState(),
      () => selector ? selector(store.getState()) : store.getState()
    );

    return selectedState as U extends undefined ? T : U;
  };
}

// Usage with actions
interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

const counterStore = createStore<CounterState>({
  count: 0,
  increment: () => {},
  decrement: () => {},
  reset: () => {},
});

// Bind actions after store creation
counterStore.setState({
  increment: () => counterStore.setState(s => ({ count: s.count + 1 })),
  decrement: () => counterStore.setState(s => ({ count: s.count - 1 })),
  reset: () => counterStore.setState({ count: 0 }),
});

const useCounter = createUseStore(counterStore);

function Counter() {
  const { count, increment, decrement, reset } = useCounter();

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}
```

The `createStore`

function establishes the core pattern: a closure that encapsulates state and listeners, exposing three methods that implement the `useSyncExternalStore`

contract. The `setState`

implementation merges partial updates with the current state using the spread operator, ensuring a new reference even when only one property changes. This reference change is what triggers React's comparison in `useSyncExternalStore`

.

The `createUseStore`

wrapper adds TypeScript overloads that support both full state selection and custom selectors. The hook calls `useSyncExternalStore`

with the store's subscribe method and a snapshot function that applies the selector. The selector runs on every call to `getSnapshot`

, which happens during render and whenever the store changes. Expensive selectors here would hurt performance, but for most applications the cost is negligible compared to component render time.

The action binding pattern addresses a common confusion with this architecture. Actions need access to `setState`

, but `setState`

is only available after `createStore`

returns. The solution is to define action placeholders in the initial state, then bind the real implementations after store creation. This two-step initialization feels awkward at first but eliminates circular dependencies and keeps the store creation function pure.

The three failure modes that break `useSyncExternalStore`

integrations all stem from violating the hook's contracts. These bugs are invisible in development and only surface in production when concurrent rendering or server hydration exposes the timing assumptions.

Snapshot mutation happens when developers modify the returned object instead of creating a new reference. A store that maintains an array and calls `array.push()`

violates immutability because the array reference stays the same. React's `Object.is`

comparison sees no change, so components don't re-render even though the data changed. The fix is always to replace the array with a new one: `[...array, newItem]`

. This requirement mirrors Redux's reducer rules and exists for the same reason—reference equality is the only performant way to detect changes.

Unstable `subscribe`

functions occur when the subscribe callback is defined inline without `useCallback`

or when it closes over changing variables. React treats function identity changes as subscription changes and runs the cleanup logic. If cleanup doesn't properly remove the old listener, the Set or array of listeners grows without bound. The symptom is components re-rendering multiple times per state change—once per accumulated listener. The fix is to ensure `subscribe`

is defined once at store creation time and returns a stable function reference.

SSR hydration mismatches manifest when `getServerSnapshot`

is omitted or returns a different value than the initial client-side call to `getSnapshot`

. The server renders the component with one value, React hydrates with another, and the mismatch causes React to assume the HTML is invalid. In production mode with selective hydration, React silently suppresses event handlers on the mismatched nodes, leading to buttons that don't respond to clicks. The fix is to provide a `getServerSnapshot`

that matches the server environment—often just returning the `initialValue`

from store creation.

The decision point for `useSyncExternalStore`

is whether the data source lives outside React's render lifecycle. Browser APIs, WebSocket connections, third-party libraries with their own state—these are external stores that change independently of component renders. Context and `useState`

are sufficient when state exists only within the component tree and React controls when it changes.

Context is the right choice for theme, locale, or authenticated user data that needs to flow down the tree but doesn't need to synchronize with external changes. The key consideration is whether the state could change while a component is suspended or rendering concurrently. If not—if state changes only happen through user actions that trigger synchronous React updates—Context handles it. The performance concern with Context isn't about the hook itself but about how often the provider value changes and re-renders consumers.

External libraries like Zustand or Jotai become valuable when multiple parts of the application need to share state that isn't naturally parent-child related. A shopping cart that three different routes access is easier to model as a global store than to lift state to a shared ancestor and prop-drill through unrelated components. These libraries use `useSyncExternalStore`

internally, so choosing them isn't avoiding the hook—it's choosing a battle-tested implementation instead of writing one from scratch.

The line between "build your own" and "use a library" depends on complexity, not just features. A global store with two or three slices of state and a handful of actions fits comfortably in 50 lines of custom code. A store with async actions, persistence, devtools integration, and complex selector patterns justifies the dependency on a library. The cost of the library is maintenance and bundle size; the cost of custom code is testing and documentation.

The most common cause is a `getSnapshot`

function that returns a new object reference on every call, violating React's `Object.is`

comparison. Ensure `getSnapshot`

caches its result and only returns a new reference when the underlying data actually changes, typically by maintaining a mutable state variable that updates only on subscription callbacks.

Yes, but the `getSnapshot`

function must always return a synchronous value representing the current state—"loading", "error", or the resolved data. The subscription logic handles async events (WebSocket messages, fetch completion) and updates the cached state, which `getSnapshot`

then returns. Never make `getSnapshot`

itself async or return a Promise.

No, the third argument is optional and only necessary when rendering on the server. For client-only apps, omit it entirely. React only calls `getServerSnapshot`

during server rendering to generate initial HTML, so providing it for a client-only app has no effect but doesn't cause errors.

The `subscribe`

function must return an unsubscribe callback that removes the listener from the store's internal Set or array. When React unmounts the component or the subscription changes, it calls this cleanup function. Forgetting to return a cleanup function or returning a non-function value prevents React from cleaning up, leaving stale listeners that continue firing after the component unmounts.

`useSyncExternalStore`

prevents tearing but doesn't prevent unnecessary renders. A selector that returns a new object on every call (`state => ({ count: state.count })`

) creates a new reference even when `count`

hasn't changed, triggering re-renders. Memoization ensures the selector returns the same reference for equal values, which React's `Object.is`

comparison then recognizes as unchanged, skipping the render.

The hook that developers dismissed as "advanced" or "library internals" is now unavoidable for anyone working with React 18+ in production. Understanding `useSyncExternalStore`

is understanding why state libraries enforce immutability, why selectors need memoization, and why Context doesn't solve every sharing problem. These aren't arbitrary rules—they're consequences of the concurrent rendering model and the guarantees this hook provides.

The practical impact is that teams building custom integrations—browser storage, WebSocket state, third-party SDK connections—now have a clear pattern instead of fragile workarounds. The days of "just use an event listener and useState" are over. That approach breaks subtly under concurrent rendering, and fixing it after the fact is expensive. Building on `useSyncExternalStore`

from the start means the integration scales from prototype to production without rewrites.

For library authors, this hook is non-negotiable. Any state management library that doesn't use `useSyncExternalStore`

internally will tear under concurrent rendering, and users will report inconsistent UI states that are impossible to reproduce reliably. The migration from custom subscription systems to this hook is why Zustand, Jotai, and Redux all had major version bumps in the React 18 era. The API surface stayed similar, but the internal implementation had to change to guarantee safety.

That covers the essential patterns for `useSyncExternalStore`

. Apply these in production and the difference will be immediate—components that stay consistent under suspense, state that synchronizes across tabs, and integrations that don't break when React introduces new concurrent features. The hook isn't just about preventing bugs; it's about building state architecture that doesn't need defensive workarounds.
