React Concurrent Rendering Tearing: When External Stores Break
React 18 concurrent rendering can pause mid-render—if external stores change during that pause, components see inconsistent data. useSyncExternalStore prevents this 'tearing' problem.
React 18's concurrent rendering can pause mid-render to handle higher-priority updates.
If your component reads from an external store (Redux, Zustand, module-scope variables)
during that pause, you might render a UI where half the components show old data and
half show new. This is called "tearing"—and useSyncExternalStore exists to prevent it.
The Problem: Mid-Render Mutations
In React 17 and earlier, rendering was synchronous. Once React started rendering your component tree, it finished before anything else could run. External store reads were safe because the store couldn't change mid-render.
// React 17: This was fine
function Counter() {
// Store can't change between this read and render completion
const count = externalStore.getCount();
return <div>{count}</div>;
}React 18's concurrent rendering changes this. React can now pause rendering to handle urgent updates (like user input), then resume. During that pause, external stores can change:
// React 18 Concurrent Mode: Danger!
function App() {
return (
<>
<Counter /> {/* Reads count = 5, renders "5" */}
{/* React pauses here for urgent update... */}
{/* ...meanwhile, store updates count to 6 */}
<Counter /> {/* Reads count = 6, renders "6" */}
</>
);
}
// Result: First Counter shows 5, second shows 6
// This is "tearing" - inconsistent UI from same data sourceWhen Tearing Happens
Tearing requires three conditions:
- Concurrent features enabled: Using
createRoot,startTransition,useDeferredValue, or Suspense for data fetching - External store: Data lives outside React's state management (Redux, Zustand, module variables, RxJS, etc.)
- Store updates during render: The external store changes while React is mid-render
// ❌ Susceptible to tearing
let count = 0; // Module-scope variable
function Counter() {
const [, forceRender] = useReducer(x => x + 1, 0);
useEffect(() => {
// Subscribe to store changes
const unsubscribe = store.subscribe(() => forceRender());
return unsubscribe;
}, []);
return <div>{count}</div>; // Direct external read!
}
// If store updates during a concurrent render,
// different Counter instances might see different valuesThe Fix: useSyncExternalStore
React 18 introduced useSyncExternalStore specifically to solve tearing.
It ensures all components see the same store snapshot during a single render pass.
import { useSyncExternalStore } from 'react';
// ✅ Tear-proof store access
function Counter() {
const count = useSyncExternalStore(
store.subscribe, // How to subscribe
store.getSnapshot, // How to get current value
store.getServerSnapshot // Optional: for SSR
);
return <div>{count}</div>;
}How It Works
useSyncExternalStore uses a special internal mechanism:
- Takes a snapshot of the store value at render start
- If the store changes during render, React detects it
- React throws away the in-progress render and restarts synchronously
- All components now see the new, consistent value
// Conceptually (simplified):
function useSyncExternalStore(subscribe, getSnapshot) {
const value = getSnapshot();
// React tracks this snapshot internally
// If getSnapshot() returns different value during render,
// React will de-opt to synchronous rendering
useEffect(() => {
return subscribe(() => {
// On store change, schedule re-render
scheduleUpdate();
});
}, [subscribe]);
return value;
}Implementing getSnapshot Correctly
The getSnapshot function must return a stable reference for unchanged data.
Returning a new object every time will cause infinite re-renders:
// ❌ BAD: New object every call
const getSnapshot = () => ({
count: store.count,
name: store.name,
});
// ✅ GOOD: Return cached/memoized value
let cachedSnapshot = null;
let cachedState = null;
const getSnapshot = () => {
const state = store.getState();
if (state !== cachedState) {
cachedState = state;
cachedSnapshot = {
count: state.count,
name: state.name,
};
}
return cachedSnapshot;
};
// ✅ ALSO GOOD: Return primitive or existing reference
const getSnapshot = () => store.count; // Primitives are always stableCommon External Stores
Redux (Already Fixed)
React-Redux v8+ uses useSyncExternalStore internally. If you're using
useSelector, you're protected:
// React-Redux v8+ - tearing-safe
import { useSelector } from 'react-redux';
function Counter() {
const count = useSelector(state => state.counter.value);
return <div>{count}</div>;
}Zustand (Already Fixed)
Zustand v4+ also uses useSyncExternalStore:
// Zustand v4+ - tearing-safe
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
function Counter() {
const count = useStore((state) => state.count);
return <div>{count}</div>;
}Custom Stores (You Need to Fix)
// ❌ Before: Custom subscription pattern
function useMyStore(selector) {
const [state, setState] = useState(selector(myStore.getState()));
useEffect(() => {
return myStore.subscribe(() => {
setState(selector(myStore.getState()));
});
}, [selector]);
return state;
}
// ✅ After: Using useSyncExternalStore
function useMyStore(selector) {
return useSyncExternalStore(
myStore.subscribe,
() => selector(myStore.getState()),
() => selector(myStore.getState()) // Server snapshot
);
}The Browser APIs Case
Browser APIs like window.innerWidth or navigator.onLine
are external stores too. Use useSyncExternalStore for them:
// ✅ Tear-proof window dimensions
function useWindowWidth() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
},
() => window.innerWidth,
() => 0 // Server: no window
);
}
// ✅ Tear-proof online status
function useOnlineStatus() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
},
() => navigator.onLine,
() => true // Server: assume online
);
}When Tearing Doesn't Matter
Not every external store read needs useSyncExternalStore:
- Read-once values: Config loaded at startup that never changes
- Event handlers: Reading latest value in onClick (not during render)
- Refs: Reading
ref.currentin effects, not render - No concurrent features: Using legacy
ReactDOM.render()(but you should migrate)
// This is fine - reading in event handler, not render
function Counter() {
const handleClick = () => {
// Latest value at click time, not render time
console.log(externalStore.count);
};
return <button onClick={handleClick}>Log</button>;
}Debugging Tearing
Tearing is hard to reproduce because it requires precise timing. Use React DevTools Profiler with "Highlight updates" and StrictMode to surface issues:
// Enable StrictMode - renders twice to expose issues
<React.StrictMode>
<App />
</React.StrictMode>
// Add logging to spot inconsistencies
function Counter({ id }) {
const count = externalStore.count;
console.log(`Counter ${id} sees count: ${count}`);
// If different counters log different values in same render,
// you have tearing
return <div>{count}</div>;
}The Performance Trade-off
useSyncExternalStore may de-opt to synchronous rendering when stores
change during concurrent renders. This is intentional—consistency beats concurrency.
If you're seeing performance issues:
- Use selectors to subscribe to minimal state slices
- Avoid frequent store updates during transitions
- Consider if the data really needs to be in an external store
Key Takeaways
- Tearing = inconsistent UI when external stores change during concurrent renders
useSyncExternalStoreensures all components see the same snapshot- Redux v8+ and Zustand v4+ already use it internally—you're protected
- Custom stores and browser APIs need manual
useSyncExternalStoreintegration getSnapshotmust return stable references for unchanged data- Reading external state in event handlers (not render) doesn't cause tearing
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement