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?);subscribe: A function that registers a callback with the external store and returns an unsubscribe cleanup function.getSnapshot: A function that returns the current state of the store.getServerSnapshot: An optional function that returns the initial state used during server-side rendering (SSR).
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
- Place a
console.log("getSnapshot called")inside yourgetSnapshotfunction. - 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
- Verify if your store’s change listeners are actually being triggered
by logging inside the
subscribesetup. - 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
- Add a
console.log("Subscribed")inside yoursubscribefunction and aconsole.log("Unsubscribed")inside the returned cleanup function. - If these logs fire on every single UI interaction or component
render, your
subscribereference 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
- Compare the HTML generated by the server with the initial state loaded on the client.
- 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);