EdgeCases Logo
Feb 2026
React
Deep
7 min read

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
concurrent-mode
useSyncExternalStore
redux
zustand
state-management
edge-case

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 source

When 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 values

The 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:

  1. Takes a snapshot of the store value at render start
  2. If the store changes during render, React detects it
  3. React throws away the in-progress render and restarts synchronously
  4. 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 stable

Common 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.current in 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
  • useSyncExternalStore ensures 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 useSyncExternalStore integration
  • getSnapshot must return stable references for unchanged data
  • Reading external state in event handlers (not render) doesn't cause tearing

Advertisement

Related Insights

Explore related edge cases and patterns

React
Deep
React useId: Why Math.random() Breaks Hydration
7 min
CSS
Deep
Font Metrics: Why Text Won't Center in Buttons
7 min

Advertisement