Articles

useSyncExternalStore: Demystified for Practical React Development

Kent C. Dodds
Kent C. Dodds

React's useSyncExternalStore is one of those hooks that most developers don't reach for every day, but when you need it, you really need it. It's crucial for integrating React components with external state management systems or browser APIs that React doesn't control. Unfortunately, it's also frequently misunderstood. Let's clear up the confusion, walk through real-world examples, and tackle the most common mistakes.

Why does useSyncExternalStore exist? The Problem of "Tearing"

React's built-in state (useState, useReducer) and Context API are excellent for managing data that lives inside your React application. But what happens when your component needs to display data from a source outside React's control? Consider:

  • Browser APIs: navigator.onLine (online status), document.visibilityState (page visibility), window.matchMedia (media queries).
  • Third-party state management libraries: Older versions of Redux or MobX, or custom-built stores that weren't designed with React's concurrent features in mind. (Note: Modern versions of libraries like Redux, Zustand, and Jotai often use useSyncExternalStore internally for their React bindings).
  • Global JavaScript variables or custom event systems: Any mutable data source that React doesn't manage.

Before useSyncExternalStore, developers typically used useEffect and useState to subscribe to these external sources and update local component state. While this worked in simpler cases, it could lead to inconsistencies with React's concurrent rendering capabilities.

Concurrent rendering allows React to work on multiple UI updates simultaneously. Pausing, resuming, or abandoning rendering work as needed. This significantly improves perceived performance. However, if an external store changes while React is in the middle of rendering a component tree, different components might read different versions of the external data. This inconsistency is called "tearing," where your UI literally "tears" by showing conflicting information.

useSyncExternalStore solves this by providing a React-managed, synchronous way to subscribe to an external store. It guarantees that all components in a render pass see the same, consistent snapshot of the data, even during concurrent updates, thus preventing tearing.

The API, Explained


const synchronizedState = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot? // Optional
);

Let's take a closer look at the arguments:

  1. subscribe(callback):

    • This function is responsible for setting up a subscription to your external data store.
    • It takes one argument: a callback function provided by React.
    • Whenever the data in the external store changes, your subscribe function must call this callback. This informs React that the store has changed and a re-render might be necessary.
    • It must return an unsubscribe function. React will call this cleanup function when the component unmounts or if the subscribe function itself changes between renders.
  2. getSnapshot():

    • This function's job is to return a snapshot of the current data from the store that your component needs.
    • It must be pure (no side effects) and synchronous (return the value immediately). React might call it multiple times, even if the store hasn't changed, so it needs to be fast.
    • Crucially, if the underlying data hasn't changed, getSnapshot should return the same value by reference (if it's an object or array) or the same primitive value as the last time it was called. This allows React to optimize re-renders using Object.is comparison. More on this in the "Common Mistakes" section.
  3. getServerSnapshot?() (Optional):

    • This function is only needed for Server-Side Rendering (SSR) and client-side hydration.
    • It should return the initial snapshot of the data as it should appear on the server.
    • If your external store is client-only (e.g., relies on browser APIs not present on the server), you might provide a default or placeholder value here.
    • If omitted and used in an SSR context, the component will typically suspend on the client until hydration, or React might throw an error if the server-rendered HTML doesn't match the initial client render.

Example: Tracking Online Status (Browser API)

Let's create a custom hook useOnlineStatus to track if the user's browser is online.


import { useSyncExternalStore } from 'react'
// 1. Define getSnapshot outside the component:
// It reads the current state from the external source.
function getOnlineStatusSnapshot() {
return navigator.onLine
}
// 2. Define subscribe outside the component:
// It sets up listeners and calls the React-provided callback on change.
function subscribeToOnlineStatus(callback) {
window.addEventListener('online', callback)
window.addEventListener('offline', callback)
// Return the cleanup function
return () => {
window.removeEventListener('online', callback)
window.removeEventListener('offline', callback)
}
}
// 3. Create the custom hook
export function useOnlineStatus() {
// useSyncExternalStore ensures synchronous reads and tearing prevention
const isOnline = useSyncExternalStore(
subscribeToOnlineStatus,
getOnlineStatusSnapshot,
// For a robust SSR scenario, you should provide getServerSnapshot:
() => true, // Assuming client is 'online' or a sensible default
)
return isOnline
}
// Usage in a component:
function StatusBar() {
const isOnline = useOnlineStatus()
return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>
}

This example demonstrates the core pattern: stable subscribe and getSnapshot functions interacting with the external source (navigator.onLine and its events).

Common Mistakes, Gotchas, and How to Get Them Right

People have asked me lots of questions about useSyncExternalStore. Here are some of the most common ones.

1. "Why not just use useEffect + useState?"

While useEffect and useState can subscribe to external stores, this pattern is prone to tearing in concurrent rendering. React might pause rendering a component, an external store might update, and then React might resume rendering with stale data or cause inconsistencies across the UI. useSyncExternalStore is specifically designed to integrate with React's rendering lifecycle, ensuring that reads are synchronous and consistent during a render pass. If you're syncing with state outside React, useSyncExternalStore is the correct, robust solution.

2. "My subscribe or getSnapshot functions are re-created on every render!"

If you define subscribe or getSnapshot inline within your component or custom hook without memoization, they will be new functions on every render.


// ❌ Bad: subscribe and getSnapshot are re-created every render
function MyComponentUsesStore() {
// These functions get new identities on each render of MyComponentUsesStore
function subscribe(callback) {
/* ... */
}
function getSnapshot() {
/* ... */
}
const value = useSyncExternalStore(subscribe, getSnapshot)
// ...
}

When useSyncExternalStore receives a new subscribe function instance, it will re-subscribe to the store (calling the old unsubscribe function first, then the new subscribe). This is inefficient and can lead to bugs or memory leaks if not handled perfectly.

Solution:

  • Define them outside your component: As shown in the useOnlineStatus example. This is the simplest and often best way.
  • Memoize them with useCallback: If your subscribe or getSnapshot functions depend on props or state (e.g., an ID to subscribe to a specific document), wrap them in useCallback.

// ✅ Good: subscribe and getSnapshot are stable
function subscribeToStore(callback) {
/* ... */
}
function getStoreSnapshot() {
/* ... */
}
function MyComponentUsesStore() {
const value = useSyncExternalStore(subscribeToStore, getStoreSnapshot)
// ...
}
// ✅ Also Good (if dependent on props, e.g., storeId):
import { useCallback, useSyncExternalStore } from 'react'
function MyComponentUsesStore({ storeId }) {
const subscribe = useCallback(
(callback) => {
return externalStoreAPI.subscribe(storeId, callback)
},
[storeId],
)
const getSnapshot = useCallback(() => {
return externalStoreAPI.getSnapshot(storeId)
}, [storeId])
const value = useSyncExternalStore(subscribe, getSnapshot)
// ...
}

3. "Why is getSnapshot called so many times?"

React may call getSnapshot multiple times during a render pass, and potentially even if no re-render occurs (e.g., to verify consistency). This is expected behavior. Therefore, getSnapshot must be:

  • Fast: Avoid expensive computations.
  • Pure: No side effects. Don't modify anything outside its scope.
  • Consistent: If the underlying store data relevant to the snapshot hasn't changed, getSnapshot must return the exact same value (referential equality for objects/arrays using Object.is).

React uses the return value of getSnapshot to determine if a re-render is needed. If it frequently returns new object/array instances even for unchanged data, it will cause unnecessary re-renders (see mistake #8 below).

4. "I get an error: useSyncExternalStore is not a function."

This almost always means you're using a version of React older than 18. useSyncExternalStore was introduced in React 18. Solution: Upgrade your react and react-dom packages to at least v18.0.0.

5. "How do I properly use this with Server-Side Rendering (SSR)?"

If your external store can provide a meaningful value on the server, you must provide the third argument, getServerSnapshot. If you don't, you'll get the following error in the console:


Missing getServerSnapshot, which is required for server-rendered content. Will
revert to client rendering.

Here's an example of how to use getServerSnapshot:


const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)

getServerSnapshot should return the initial state of the store as it should be rendered on the server. For example, if you're syncing with localStorage (which doesn't exist on the server), getServerSnapshot might return null or a default value. Just know that this could cause a flash of incorrect content. So you'll want to come up with a creative design to handle this (or even better, use a cookie or a server-side state management solution for this).

If you omit getServerSnapshot and the component renders on the server, React expects the initial client-side render (after hydration) to match the server-rendered HTML. If getSnapshot() on the client returns something different from what was implicitly rendered on the server (or if the server can't render it), you'll get a hydration mismatch error. If the component using the hook suspends on the server (e.g. because it can't get a value), then it will also suspend on the client during hydration until the subscribe function is called and a client-side snapshot is available.

6. "Can I use this with Next.js, Remix, or other SSR frameworks?"

Yes, absolutely! This hook is essential for safely using external stores in such environments.

  • If the store is server-compatible: Provide getServerSnapshot.
  • If the store is browser-only (e.g., window.matchMedia):
    • getServerSnapshot should return a sensible default (e.g., false for a media query, or true for navigator.onLine).
    • The client-side getSnapshot will then provide the actual browser value upon hydration. React will ensure a smooth transition.
    • Example for window.matchMedia:

      // In your hook
      const getServerSnapshot = () => false // Or whatever default makes sense
      // ...
      useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)

    • Alternatively, if no sensible server default exists, you might conditionally render the component only on the client, or wrap it in <Suspense> if not providing getServerSnapshot and expect it to suspend during hydration.

7. "When should I use this instead of Redux, Zustand, Jotai, or React Context?"

This is a common point of confusion.

  • React Context: Use Context for React state that needs to be shared across a component tree without prop drilling. It's for state managed by React.
  • Redux, Zustand, Jotai, etc.: Modern versions of these libraries often use useSyncExternalStore internally to bridge their external store logic with React's concurrent rendering. As an end-user of these libraries, you typically use their provided hooks (useSelector, useStore) and don't call useSyncExternalStore directly.

You, as an application developer, would directly use useSyncExternalStore when:

  1. Integrating with a non-React-aware external store: This could be a third-party vanilla JS library, a global variable, a Web Worker, or any system that manages state outside of React and doesn't have its own React binding.
  2. Subscribing directly to browser APIs: Like navigator.onLine, document.visibilityState, window.matchMedia, etc.
  3. Building your own state management library: If you're authoring a new state management solution, useSyncExternalStore is the primitive you'd use to make it compatible with React's concurrent features.

So, it's not an "either/or" with libraries like Zustand; rather, useSyncExternalStore is often an implementation detail for them or a tool for you when those libraries aren't the right fit for interfacing with a particular external data source.

8. "How do I avoid infinite loops or unnecessary re-renders due to getSnapshot?"

React uses Object.is() to compare the previous snapshot with the current one returned by getSnapshot. If getSnapshot returns a new object or array reference on every call, React will think the state has changed, leading to re-renders even if the underlying data is identical.


// External store (example)
const myExternalStore = {
_data: { user: { name: 'Alex', preferences: { theme: 'dark' } } },
listeners: [],
getData() {
return this._data
},
subscribe(listener) {
/* ... */ return () => {
/* ... */
}
},
// ... methods to update _data and notify listeners
}
// ❌ Bad: getSnapshot always returns a new object
function getPreferencesSnapshot_Bad() {
// Even if preferences haven't changed, this is a new object instance every time.
return { ...myExternalStore.getData().user.preferences }
}
// ✅ Good: Return the same object reference if data hasn't changed.
// This requires your store or your snapshot logic to be a bit smarter.
// Option 1: If the store itself manages immutable data for selections.
function getPreferencesSnapshot_Good_Immutable() {
// Assumes myExternalStore.getData().user.preferences *is* the immutable object,
// replaced only when it actually changes.
return myExternalStore.getData().user.preferences
}
// Option 2: Manually cache the derived snapshot.
let lastKnownPreferences = myExternalStore.getData().user.preferences
let cachedPreferencesSnapshot = { ...lastKnownPreferences }
function getPreferencesSnapshot_Good_Cached() {
const currentPreferences = myExternalStore.getData().user.preferences
// Shallow comparison; for deep objects, you might need a deep comparison
// or ensure the store replaces `currentPreferences` by reference on any nested change.
if (currentPreferences !== lastKnownPreferences) {
cachedPreferencesSnapshot = { ...currentPreferences }
lastKnownPreferences = currentPreferences
}
return cachedPreferencesSnapshot
}

The key is referential stability: If the data didn't change, getSnapshot must return the exact same object instance as before. If it's a primitive (string, number, boolean), this is less of an issue unless you're re-computing it unnecessarily.

Bonus: Reusable useMediaQuery Hook

Here's how you can build a reusable hook for media queries, ensuring subscribe and getSnapshot are stable using useCallback if the query can change. This is from the Epic React workshop on Advanced React APIs.


import { Suspense, useSyncExternalStore } from 'react'
import * as ReactDOM from 'react-dom/client'
export function makeMediaQueryStore(mediaQuery: string) {
function getSnapshot() {
return window.matchMedia(mediaQuery).matches
}
function subscribe(callback: () => void) {
const mediaQueryList = window.matchMedia(mediaQuery)
mediaQueryList.addEventListener('change', callback)
return () => {
mediaQueryList.removeEventListener('change', callback)
}
}
return function useMediaQuery() {
return useSyncExternalStore(subscribe, getSnapshot)
}
}
const useNarrowMediaQuery = makeMediaQueryStore('(max-width: 600px)')
function NarrowScreenNotifier() {
const isNarrow = useNarrowMediaQuery()
return isNarrow ? 'You are on a narrow screen' : 'You are on a wide screen'
}
function App() {
return (
<div>
<div>This is your narrow screen state:</div>
<Suspense fallback="...loading...">
<NarrowScreenNotifier />
</Suspense>
</div>
)
}
const root = ReactDOM.hydrateRoot(rootEl, <App />, {
onRecoverableError(error) {
if (String(error).includes('Missing getServerSnapshot')) return
console.error(error)
},
})

Note: In the useMediaQuery example, useEffect with useState might seem simpler for client-only scenarios if you are not in concurrent mode or don't deeply care about tearing for this specific feature. However, useSyncExternalStore is the most robust way to handle this, especially with SSR and concurrent features. Notice that even though we're assuming server rendering (with hydrateRoot), we're not supplying a getServerSnapshot because we don't have a way to check the media query on the server. So we add a onRecoverableError handler to avoid the unnecessary error logging.

Troubleshooting Checklist

When debugging useSyncExternalStore:

  • React Version: Are you on React 18 or newer?
  • Stable Functions: Are your subscribe and getSnapshot functions stable (defined outside the component or memoized with useCallback)?
  • getSnapshot Purity & Performance: Is getSnapshot fast, pure, and does it return the same value reference (Object.is true) if the underlying data hasn't changed?
  • subscribe Correctness: Does subscribe correctly call the React-provided callback only when the store actually changes? Does it return a proper unsubscribe function?
  • SSR: If using SSR, have you provided a getServerSnapshot function? Does it return a value consistent with what the client might see initially or a safe default?
  • External State Only: Are you certain you're using this for state truly external to React? React state and context have their own mechanisms.

Wrap-up

useSyncExternalStore is a specialized but powerful hook for safely connecting React components to external data sources in the concurrent era. By understanding its purpose (to prevent tearing and ensure consistent reads) and by following these best practices, you can confidently integrate React with any external state management system or browser API.

Happy (and safe) syncing! And check out the Epic React workshop on Advanced React APIs for more advanced patterns and use cases!

Get my free 7-part email course on React!

Delivered straight to your inbox.