How to Debug useSyncExternalStore in React

The React hook useSyncExternalStore is a powerful tool for subscribing to external data stores, but it can introduce subtle bugs like infinite render loops, stale UI states, and hydration mismatches. This article provides a practical guide to debugging these issues by analyzing the hook’s core mechanics. You will learn how to identify common pitfalls in subscription handling, optimize your snapshot caching, and ensure smooth state synchronization between your external store and your React components.

Understanding the Hook’s Signature

To effectively debug useSyncExternalStore, you must understand its three parameters:

const state = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);

Most bugs with this hook stem from how React compares the return values of these functions.


Issue 1: Infinite Render Loops (The getSnapshot Trap)

The most common bug when using useSyncExternalStore is an infinite render loop. React calls getSnapshot frequently to check if the store’s state has changed. If getSnapshot returns a new object reference on every call, React assumes the store has updated and triggers a re-render, creating a loop.

The Buggy Code

// BAD: Returns a new array reference every time it is called
const getSnapshot = () => {
  return store.getItems().filter(item => item.active); 
};

The Debugging Steps

  1. Place a console.log("getSnapshot called") inside your getSnapshot function.
  2. If you see this log flooding your console endlessly, you are returning a new object reference on every call.

The Fix

Ensure getSnapshot returns an immutable, cached reference if the underlying data has not changed.

// GOOD: Returns a cached reference
let cachedActiveItems = [];
let lastRawItems = null;

const getSnapshot = () => {
  const currentItems = store.getItems();
  if (currentItems !== lastRawItems) {
    cachedActiveItems = currentItems.filter(item => item.active);
    lastRawItems = currentItems;
  }
  return cachedActiveItems;
};

Issue 2: React Fails to Re-render on Store Updates

If your external store updates but your React UI does not change, the issue lies within your subscribe function or your state mutation strategy.

The Buggy Code

React uses Object.is to compare the value returned by getSnapshot with the previous snapshot value. If you mutate your external store directly, getSnapshot will return the same object reference, and React will skip the update.

// BAD: Mutating the store directly
const store = {
  state: { count: 0 },
  increment() {
    this.state.count++; // Mutation! Reference of 'state' remains the same.
    this.notify();
  }
};

The Debugging Steps

  1. Verify if your store’s change listeners are actually being triggered by logging inside the subscribe setup.
  2. Check if your store update creates a new object reference.

The Fix

Always return a new object or array reference from your store when a change occurs.

// GOOD: Creating a new object reference on update
const store = {
  state: { count: 0 },
  increment() {
    this.state = { count: this.state.count + 1 }; // New reference created
    this.notify();
  }
};

Issue 3: Unstable subscribe Functions

If React continuously resubscribes to your store (unsubscribing and resubscribing on every single render), your subscribe function reference is likely unstable.

The Buggy Code

If you define the subscribe function inside your component body, React will treat it as a new function on every render, causing unnecessary cleanup and setup cycles.

function MyComponent() {
  // BAD: Redefined on every render
  const subscribe = (callback) => {
    store.subscribe(callback);
    return () => store.unsubscribe(callback);
  };
  
  const state = useSyncExternalStore(subscribe, store.getSnapshot);
}

The Debugging Steps

  1. Add a console.log("Subscribed") inside your subscribe function and a console.log("Unsubscribed") inside the returned cleanup function.
  2. If these logs fire on every single UI interaction or component render, your subscribe reference is unstable.

The Fix

Define your subscribe function outside the component, or wrap it in useCallback if it depends on component props.

// GOOD: Defined outside the component
const subscribe = (callback) => {
  store.subscribe(callback);
  return () => store.unsubscribe(callback);
};

function MyComponent() {
  const state = useSyncExternalStore(subscribe, store.getSnapshot);
}

Issue 4: Hydration Errors in Next.js or SSR Environments

If you are using Server-Side Rendering (SSR) and get a “Hydration Mismatch” error, your client-side store state does not match the server-rendered HTML.

The Debugging Steps

  1. Compare the HTML generated by the server with the initial state loaded on the client.
  2. Look for differences in timezones, local storage reads, or random values.

The Fix

You must provide the third argument, getServerSnapshot, which returns a consistent, predictable initial value for the server render.

const getSnapshot = () => window.localStorage.getItem('theme');
const getServerSnapshot = () => 'light'; // Fallback for the server

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