Type something to search...
useSyncExternalStore: Subscribing to External Data Sources Safely

useSyncExternalStore: Subscribing to External Data Sources Safely

Not all state lives in React. The browser knows whether you're online, how wide the window is, and what's in localStorage. A WebSocket client holds the latest messages. A small vanilla JavaScript store might be shared between a React app and some legacy code. To show any of that in a component, you need to read the value and re-render when it changes.

The traditional approach is useState plus useEffect: read the value into state, subscribe in an effect, and call the setter when the source changes. It mostly works, but it has subtle bugs. The first render shows a stale default, there's a window where an update can be missed between render and subscribe, and with concurrent rendering, different components can show different values from the same source during one render. React calls that last problem tearing.

useSyncExternalStore is the hook React provides for exactly this job. This post explains what problem it solves, how its three arguments work, how to build reusable hooks and a tiny store with it, how to make it server-render correctly, and the mistakes that cause infinite loops.

The Problem With useEffect Subscriptions

Here's the classic online-status hook:

import { useEffect, useState } from "react";

export function useOnlineStatusOld() {
  const [isOnline, setIsOnline] = useState(true);

  useEffect(() => {
    const update = () => setIsOnline(navigator.onLine);
    update();
    window.addEventListener("online", update);
    window.addEventListener("offline", update);
    return () => {
      window.removeEventListener("online", update);
      window.removeEventListener("offline", update);
    };
  }, []);

  return isOnline;
}

It has three weaknesses:

  1. The first render is wrong. It always renders true first, then corrects itself after the effect runs, which can cause a visible flash.
  2. Every component keeps its own copy. Ten components using the hook means ten pieces of state and ten listeners, each updating separately.
  3. Tearing under concurrent rendering. When React renders a transition, it can pause partway through and let the browser run. If the external value changes during that pause, components rendered before the pause saw the old value and components rendered after see the new one. The committed UI is inconsistent.

React can't prevent tearing on its own because it doesn't know the value came from outside. useSyncExternalStore tells it.

The API

const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);
  • subscribe(callback): Start listening to the source. Call callback whenever the data might have changed. Return a function that unsubscribes.
  • getSnapshot(): Return the current value. React calls this during render and after every notification, and compares results with Object.is. If the value differs, the component re-renders.
  • getServerSnapshot() (optional): Return the value to use during server rendering and during hydration on the client.

React handles the rest: it subscribes once per component, checks for changes it might have missed between render and subscription, and if the snapshot changes in the middle of a concurrent render, it throws away the inconsistent work and re-renders synchronously so every component sees the same value.

Example 1: Online Status

Rewritten with useSyncExternalStore, the online hook is shorter and correct on the first render:

import { useSyncExternalStore } from "react";

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

function getSnapshot() {
  return navigator.onLine;
}

function getServerSnapshot() {
  return true; // Assume online when rendering on the server
}

export function useOnlineStatus() {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

Notice that subscribe and getSnapshot are defined outside the component. That makes them stable references. If you define subscribe inline, React sees a new function every render and unsubscribes and resubscribes each time. It still works, but it's wasteful.

Using it:

import { useOnlineStatus } from "./useOnlineStatus";

export function StatusBar() {
  const isOnline = useOnlineStatus();
  return <p>{isOnline ? "Connected" : "Offline: changes will sync later"}</p>;
}

Example 2: Media Queries

window.matchMedia returns a MediaQueryList that fires change events. Because the query is a parameter, subscribe needs access to it. useCallback keeps the function stable across renders:

import { useCallback, useSyncExternalStore } from "react";

export function useMediaQuery(query: string, serverFallback = false) {
  const subscribe = useCallback(
    (callback: () => void) => {
      const mql = window.matchMedia(query);
      mql.addEventListener("change", callback);
      return () => mql.removeEventListener("change", callback);
    },
    [query],
  );

  const getSnapshot = () => window.matchMedia(query).matches;
  const getServerSnapshot = () => serverFallback;

  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

getSnapshot can safely be inline here because it returns a boolean. React only cares that its return value is stable when nothing changed, and booleans compare by value.

import type { ReactNode } from "react";
import { useMediaQuery } from "./useMediaQuery";

export function Layout({ children }: { children: ReactNode }) {
  const isWide = useMediaQuery("(min-width: 1024px)");
  return isWide ? <aside className="sidebar">{children}</aside> : <details>{children}</details>;
}

This is also the core of a system-preference dark mode hook using (prefers-color-scheme: dark). If you're building one, the dark mode toggle guide shows how to combine it with a saved preference.

The Golden Rule: getSnapshot Must Return a Cached Value

This is where most bugs come from. React calls getSnapshot repeatedly and expects the same value back when the data hasn't changed. If you return a new object or array every call, React thinks the store changed every time, re-renders, calls getSnapshot again, gets another new object, and loops. You'll see an error like "The result of getSnapshot should be cached to avoid an infinite loop."

// Broken: returns a new object on every call
function getSnapshot() {
  return { width: window.innerWidth, height: window.innerHeight };
}

There are two fixes. Return primitives where possible, using separate hooks or separate calls:

import { useSyncExternalStore } from "react";

function subscribeResize(callback: () => void) {
  window.addEventListener("resize", callback);
  return () => window.removeEventListener("resize", callback);
}

export function useWindowWidth() {
  return useSyncExternalStore(
    subscribeResize,
    () => window.innerWidth,
    () => 0,
  );
}

Or cache the object and only replace it when the underlying values change:

type Size = { width: number; height: number };

let cachedSize: Size = { width: 0, height: 0 };

function getSizeSnapshot(): Size {
  const { innerWidth: width, innerHeight: height } = window;
  if (width !== cachedSize.width || height !== cachedSize.height) {
    cachedSize = { width, height };
  }
  return cachedSize;
}

A nice side effect of returning primitives: a component that only reads the width won't re-render when only the height changes.

Example 3: Syncing With localStorage

localStorage fires a storage event, but only in other tabs, not the one that made the change. To keep the current tab in sync too, you dispatch your own notification when writing:

import { useCallback, useSyncExternalStore } from "react";

const listeners = new Set<() => void>();

function notify() {
  listeners.forEach((l) => l());
}

function subscribe(callback: () => void) {
  listeners.add(callback);
  window.addEventListener("storage", callback);
  return () => {
    listeners.delete(callback);
    window.removeEventListener("storage", callback);
  };
}

export function useLocalStorage(key: string, initialValue: string) {
  const getSnapshot = () => localStorage.getItem(key) ?? initialValue;
  const getServerSnapshot = () => initialValue;

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

  const setValue = useCallback(
    (next: string) => {
      localStorage.setItem(key, next);
      notify();
    },
    [key],
  );

  return [value, setValue] as const;
}

The snapshot is a string, so it's naturally stable. If you want to store objects, keep the raw string as the snapshot and parse it with useMemo in the hook, otherwise JSON.parse would return a new object every call.

export function ThemePicker() {
  const [theme, setTheme] = useLocalStorage("theme", "light");
  return (
    <select value={theme} onChange={(e) => setTheme(e.target.value)}>
      <option value="light">Light</option>
      <option value="dark">Dark</option>
    </select>
  );
}

Open two tabs, change the theme in one, and both update.

Example 4: A Tiny Global Store

useSyncExternalStore is the foundation that libraries like Zustand and React Redux use to connect to React. You can build a minimal version yourself in about 30 lines, which is a good way to understand those libraries:

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

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

  return {
    getState: () => state,
    setState(updater: (prev: T) => T) {
      const next = updater(state);
      if (Object.is(next, state)) return;
      state = next;
      listeners.forEach((l) => l());
    },
    subscribe(listener: Listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}
// useStore.ts
import { useSyncExternalStore } from "react";
import { createStore } from "./createStore";

type CartState = { items: { id: string; qty: number }[] };

export const cartStore = createStore<CartState>({ items: [] });

export function useCart<S>(selector: (state: CartState) => S): S {
  return useSyncExternalStore(
    cartStore.subscribe,
    () => selector(cartStore.getState()),
    () => selector(cartStore.getState()),
  );
}

export function addToCart(id: string) {
  cartStore.setState((s) => {
    const existing = s.items.find((i) => i.id === id);
    return {
      items: existing
        ? s.items.map((i) => (i.id === id ? { ...i, qty: i.qty + 1 } : i))
        : [...s.items, { id, qty: 1 }],
    };
  });
}
import { addToCart, useCart } from "./useStore";

export function CartCount() {
  const count = useCart((s) => s.items.reduce((n, i) => n + i.qty, 0));
  return <span>Cart ({count})</span>;
}

export function AddButton({ id }: { id: string }) {
  return <button onClick={() => addToCart(id)}>Add to cart</button>;
}

The store updates immutably, so getState() returns the same reference until something changes. The selector returns a number, so CartCount only re-renders when the total changes. One caveat: a selector that builds a new array or object, like (s) => s.items.map((i) => i.id), breaks the caching rule. Real libraries solve this with memoized selectors or shallow comparison, which is why it's usually better to reach for a library like Zustand once your store grows.

Server Rendering and Hydration

On the server there's no window, so getSnapshot would throw. React uses getServerSnapshot instead, both on the server and during hydration on the client, so the initial client render matches the HTML. After hydration, React switches to getSnapshot and re-renders if the real value differs.

If you omit getServerSnapshot and the component is server-rendered, React throws an error. If your component genuinely can't render anything meaningful on the server, you can deliberately throw from getServerSnapshot and wrap the component in a <Suspense> boundary. React will show the fallback in the server HTML and render the real component on the client.

When Updates Are Always Synchronous

Updates triggered by an external store can't be marked as non-blocking. If the store changes during a transition, React stops the transition and re-renders synchronously, because consistency matters more than responsiveness here. This means wrapping a store setter in startTransition won't defer the resulting render the way it does for useState. If you need that behavior, keep the deferred part in React state, or apply useDeferredValue to the store value. See useTransition and useDeferredValue for smoother UIs.

Common Mistakes With useSyncExternalStore

  • Returning a new object from getSnapshot. This causes an infinite loop. Return primitives, cache the object, or keep your store immutable.
  • Defining subscribe inline without useCallback. React resubscribes on every render. Move it outside the component or memoize it.
  • Mutating store state in place. If you push to an array and return the same reference, Object.is sees no change and components don't update. Always create a new state object.
  • Forgetting getServerSnapshot in SSR apps. The server render fails. Provide a sensible default that matches what the server should output.
  • Using it for React-owned state. If the data is created and updated inside React, useState, useReducer, or context are simpler. useSyncExternalStore is for data that lives outside React.

Frequently Asked Questions (FAQ) About useSyncExternalStore

Tearing is when different parts of the UI show different values for the same data during one render. It can happen with concurrent rendering because React may pause between components, and an external value can change during that pause. useSyncExternalStore detects this and re-renders synchronously so the UI stays consistent.

Yes, when you're reading a value from an external source and rendering it. It avoids the stale first render, handles concurrent rendering safely, and supports server rendering. Use useEffect for subscriptions that trigger side effects rather than render data, such as logging or syncing to an analytics service.

Your getSnapshot returns a different value each time it's called even when nothing changed, usually a new object or array. React compares snapshots with Object.is, so it keeps re-rendering. Return a primitive or a cached reference that only changes when the data does.

Yes. React Redux and Zustand both build their React bindings on useSyncExternalStore, which is how they stay consistent under concurrent rendering. Most app code doesn't need to call it directly when using those libraries.

Yes. Wrap it in useCallback with the prop in the dependency array. When the prop changes, React unsubscribes from the old source and subscribes to the new one. Keep the dependency list small so you don't resubscribe more often than needed.

Not directly. Store updates always render synchronously so the UI never tears. If part of the UI is expensive, pass the store value through useDeferredValue before giving it to the slow component.

Conclusion

useSyncExternalStore is the correct way to read data that lives outside React. Give it a stable subscribe function, a getSnapshot that returns the same value until the data changes, and a getServerSnapshot for server rendering. In return you get correct first renders, shared subscriptions, and no tearing under concurrent rendering.

Start by replacing any useState plus useEffect subscription hooks in your codebase, such as online status, media queries, or window size. Once you're comfortable, read the source of a small library like Zustand and you'll recognize the same pattern at its core.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading