useSyncExternalStore Hook
useSyncExternalStore Hook
Level 11 — Ecosystem Libraries A built-in React 18 hook for subscribing components to external non-React data stores safely without concurrent rendering tearing.
1. Prerequisites
- State Management (Redux / Zustand) — The concept of external data stores operating outside React's Fiber tree.
- Rules of Hooks — The usage guidelines governing hook execution and lifecycle subscriptions.
2. Term Category
Core Hook (external store synchronization): useSyncExternalStore is a built-in React 18 hook designed to subscribe React components to state stores located outside React's internal fiber tree (such as Redux, Zustand, vanilla JavaScript event emitters, or browser web APIs like navigator.onLine and window.innerWidth).
With the introduction of Concurrent Rendering in React 18, React can pause, yield, and resume rendering cycles to prioritize urgent user interactions. If a component reads from an external non-React store that mutates while React is paused mid-render, different parts of the component tree can render using different store snapshots. This inconsistency is called Tearing. useSyncExternalStore prevents tearing by enforcing synchronous, consistent snapshot evaluations during concurrent renders.
3. Explanation
(1) Design Motivation — "Why did we design this?"
Before React 18, subscribing components to external JavaScript stores was typically implemented using useEffect + useState:
// Pre-React 18 subscription pattern (Vulnerable to Tearing in Concurrent React)
const [state, setState] = useState(externalStore.getState());
useEffect(() => {
const unsubscribe = externalStore.subscribe(() => {
setState(externalStore.getState());
});
return unsubscribe;
}, []);
While this pattern worked under React 17's synchronous rendering pipeline, it fails under React 18's Concurrent Architecture:
- Tearing Risk: If
externalStoremutates while React pauses rendering mid-tree, components rendered before the pause show Old State, while components rendered after the pause show New State, causing visible UI tearing. - Hydration Mismatch: Server-Side Rendered (SSR) components reading browser APIs often render mismatched snapshots on initial client hydration.
React 18 introduced useSyncExternalStore to resolve both issues:
subscribe: Registers a subscription callback with the external store and returns an unsubscribe function.getSnapshot: Returns a snapshot of the current state of the external store. React compares snapshot return values usingObject.ischeck to determine if re-rendering is needed.getServerSnapshot(Optional): Provides a static snapshot during Server-Side Rendering (SSR) to prevent hydration mismatches.
(2) Reality Metaphor
Imagine a electronic stock ticker wall at a stock exchange.
- Unsynchronized Tearing (Uncontrolled Screen Refresh): Three giant digital screens on the wall display the price of Stock X. The screens read from a live ticker wire (external non-React store). If the price updates from 105 while the electrician is updating the screens one by one, Screen 1 displays 105, causing confusion for traders on the floor (UI tearing bug).
- Camera Snapshot Sync (
useSyncExternalStore): Before updating any screen, a camera takes a high-speed frozen snapshot photo of the ticker (100, the camera takes a new photo ($105), ensuring all screens update in perfect sync without displaying split numbers (preventing tearing).
(3) React Code Examples
Short Snippet
// ConnectionStatus.jsx (React 18 useSyncExternalStore)
import { useSyncExternalStore } from 'react';
// 1. Subscription function registering event listeners
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
// 2. Snapshot reader function
function getSnapshot() {
return navigator.onLine; // Returns primitive boolean
}
export function ConnectionStatus() {
// Synchronously subscribes to browser network status without tearing
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
return (
<div className={`status-pill ${isOnline ? 'online' : 'offline'}`}>
{isOnline ? '🟢 Online' : '🔴 Offline'}
</div>
);
}
Fuller Example
// VanillaStore.js (External Non-React State Store)
let currentState = { count: 0 };
const listeners = new Set();
export const vanillaStore = {
getState() {
return currentState;
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
increment() {
currentState = { count: currentState.count + 1 };
listeners.forEach(listener => listener());
}
};
// CounterComponent.jsx
import React, { useSyncExternalStore, useCallback } from 'react';
import { vanillaStore } from './VanillaStore';
export function CounterComponent() {
// Subscribe to external store
const subscribe = useCallback((callback) => vanillaStore.subscribe(callback), []);
const getSnapshot = useCallback(() => vanillaStore.getState().count, []);
const count = useSyncExternalStore(subscribe, getSnapshot);
return (
<div className="store-card">
<h3>Vanilla JS Store Count: {count}</h3>
<button onClick={() => vanillaStore.increment()}>Increment External Store</button>
</div>
);
}
4. Common Mistakes & Pitfalls
Mistake 1: Returning a newly instantiated object or array from getSnapshot() on every execution
The mistake: Instantiating a new object { width: window.innerWidth } inside the getSnapshot callback.
Why it's wrong: React compares snapshots returned by getSnapshot() using Object.is check. If getSnapshot() returns a new object reference on every call, React assumes the store state has changed and triggers a re-render. This causes another call to getSnapshot(), which returns another new object, triggering an infinite render loop!
Incorrect:
// ❌ Triggers infinite rendering loop! New object reference returned every call!
const dimensions = useSyncExternalStore(
subscribe,
() => ({ width: window.innerWidth })
);
Fix:
// Return primitive values (strings, numbers, booleans) or cached immutable object references
const width = useSyncExternalStore(
subscribe,
() => window.innerWidth // Primitive number reference is stable
);
Mistake 2: Omitting the 3rd getServerSnapshot parameter in SSR applications
The mistake: Using useSyncExternalStore(subscribe, getSnapshot) in an SSR (Server-Side Rendering) application without providing the 3rd getServerSnapshot argument.
Why it's wrong: During SSR on a Node.js server, browser APIs (like window or navigator) are undefined. Without getServerSnapshot, React cannot determine initial HTML state on the server, throwing hydration mismatch warnings during client hydration.
Incorrect:
// ❌ Missing 3rd SSR server snapshot param! Throws hydration mismatch in SSR!
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
Fix:
// Provide static server snapshot fallback for SSR
const isOnline = useSyncExternalStore(
subscribe,
getSnapshot,
() => true // SSR server snapshot fallback
);
Mistake 3: Defining inline subscribe or getSnapshot functions directly inside component render bodies
The mistake: Writing inline arrow functions for subscribe or getSnapshot without memoizing them or defining them outside the component.
Why it's wrong: Passing un-memoized inline functions causes useSyncExternalStore to resubscribe to the external store on every single component render pass.
Incorrect:
// ❌ Re-subscribes on every component render pass!
function App() {
const state = useSyncExternalStore(
(cb) => store.subscribe(cb), // Inline function re-created every render!
() => store.get()
);
}
Fix:
// Define subscribe and getSnapshot functions outside component scope or with useCallback
function subscribe(cb) { return store.subscribe(cb); }
function getSnapshot() { return store.get(); }
function App() {
const state = useSyncExternalStore(subscribe, getSnapshot);
}
5. Practice Exercises
Exercise 1: IoT Mobile Window Orientation Store Subscription
Scenario: Create a custom React hook useWindowDimensions that subscribes to browser resize events using useSyncExternalStore to track screen width without tearing or infinite rendering loops.
Requirements:
- Define
subscribefunction adding/removing'resize'event listeners. - Define
getSnapshotreturning primitivewindow.innerWidth. - Provide
getServerSnapshotfallback returning default1024.
Answer
Implementation
'use client';
import { useSyncExternalStore } from 'react';
function subscribe(callback) {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
}
function getSnapshot() {
return window.innerWidth; // Returns primitive number
}
function getServerSnapshot() {
return 1024; // Static fallback for SSR server render
}
export function useWindowWidth() {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
export function WindowWidthDisplay() {
const width = useWindowWidth();
return (
<div className="width-card">
<h3>Viewport Width: {width}px</h3>
<p>Layout Mode: {width < 768 ? 'Mobile' : 'Desktop'}</p>
</div>
);
}
Technical Explanation
- Primitive Snapshot Stability:
getSnapshot()returns primitivewindow.innerWidth(number), preventing reference instability loops. - Clean Event Cleanup:
subscribefunction returns cleanup callbackremoveEventListener. - SSR Hydration Guard:
getServerSnapshotreturns static fallback1024during server render to prevent hydration errors. - Tearing Prevention: Guarantees all layout components read the exact same window width during concurrent React renders.
Exercise 2: Financial Trading Order Book Vanilla Store Subscription
Scenario: Subscribe a React trading component to an external vanilla JavaScript market depth store using useSyncExternalStore.
Requirements:
- Define external
marketStoreobject withsubscribeandgetSnapshot. - Ensure
getSnapshotreturns immutable price snapshot. - Render live market price in component.
Answer
Implementation
'use client';
import { useSyncExternalStore } from 'react';
// External vanilla JS market store
let currentPrice = 185.50;
const listeners = new Set();
export const marketStore = {
subscribe(callback) {
listeners.add(callback);
return () => listeners.delete(callback);
},
getSnapshot() {
return currentPrice;
},
updatePrice(newPrice) {
currentPrice = newPrice;
listeners.forEach(cb => cb());
}
};
export function LiveTickerDisplay() {
const price = useSyncExternalStore(marketStore.subscribe, marketStore.getSnapshot);
return (
<div className="ticker-card">
<h4>AAPL Live Stock Quote</h4>
<p className="price-tag">${price.toFixed(2)}</p>
<button onClick={() => marketStore.updatePrice(price + 0.50)}>
Simulate Market Tick (+$0.50)
</button>
</div>
);
}
Technical Explanation
- External Non-React Integration: Links vanilla JavaScript event emitter store directly to React rendering queue.
- Synchronous Snapshot Check:
Object.ischeck evaluates primitivecurrentPricenumber. - Zero Tearing: Component receives atomic price updates without tearing across concurrent sub-trees.
- Encapsulated Subscription: Handles subscription lifecycles automatically without manual
useEffectdependencies.
Exercise 3: E-Commerce Media Query Listener
Scenario: Implement a custom hook useMediaQuery(query) that uses useSyncExternalStore to subscribe to browser window.matchMedia query updates (e.g. (prefers-color-scheme: dark)).
Requirements:
- Create
useMediaQueryaccepting CSS media query string. - Register
matchMedialistener callback insidesubscribe. - Return boolean match state.
Answer
Implementation
'use client';
import { useSyncExternalStore, useCallback } from 'react';
export function useMediaQuery(query) {
const subscribe = useCallback((callback) => {
const matchMedia = window.matchMedia(query);
matchMedia.addEventListener('change', callback);
return () => matchMedia.removeEventListener('change', callback);
}, [query]);
const getSnapshot = useCallback(() => {
return window.matchMedia(query).matches;
}, [query]);
const getServerSnapshot = useCallback(() => false, []);
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
export function ThemePreferenceCard() {
const isDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
return (
<div className={`theme-card ${isDarkMode ? 'dark' : 'light'}`}>
<p>System Preference: {isDarkMode ? '🌙 Dark Mode' : '☀️ Light Mode'}</p>
</div>
);
}
Technical Explanation
- Dynamic Media Subscription:
matchMedia.addEventListener('change', callback)registers OS media query changes. - Memoized Handlers:
useCallbackmemoizessubscribeandgetSnapshotbased onquerystring parameter. - Boolean Snapshot: Returns primitive boolean
matchesvalue to maintain snapshot reference stability. - Cross-Component Consistency: All components calling
useMediaQueryreceive perfectly synchronized updates.
6. Related Terms
- State Management (Redux / Zustand) — The global state container architectures integrated by
useSyncExternalStore. - Rules of Hooks — Guidelines governing hook execution.
- Zustand — State management library utilizing
useSyncExternalStoreunder the hood. - Redux — Global state container library integrated via
useSyncExternalStore.
7. Key Takeaways
useSyncExternalStoresubscribes components to external non-React state stores safely without UI tearing.- Replaces legacy
useEffect+useStatesubscription patterns vulnerable to concurrent rendering bugs. - Requires
subscribeandgetSnapshotfunctions. getSnapshot()MUST return a primitive value or memoized immutable object to prevent infinite re-render loops.- Supply the optional 3rd
getServerSnapshotargument in SSR applications to prevent hydration mismatch warnings. - Primarily used by state library authors (Redux, Zustand) and custom browser API hook developers.