Fixing React Tearing with useSyncExternalStore

6 min read

Fixing React Tearing with useSyncExternalStore

While standard state hooks like useState and useReducer work seamlessly within React’s standard component tree, managing state from sources outside React’s render lifecycle such as browser APIs, WebSockets, RxJS observables, or state libraries like Redux and Zustand presents a fundamental challenge in Concurrent React.

useSyncExternalStore is React’s solution to concurrent tearing. This deep dive explores how useSyncExternalStore works under the hood, how to build robust selector pipelines with structural sharing, and how to subscribe to complex non-React APIs.

1. The Core Problem: Understanding Tearing

In Concurrent React, rendering is interruptible. React can start rendering a component tree, pause execution to yield to a higher-priority event (like user input), and resume rendering later.

Thread Timeline (With Tearing):
----------------------------------------------------------------------
[Render Component A] ---> (State mutates in external store) ---> [Render Component B]
      |                                                                |
  Reads Val: 1                                                    Reads Val: 2
----------------------------------------------------------------------
Result: A and B display inconsistent UI states for the exact same store!

If an external store mutates while React is paused mid-render, components rendered before the pause will read the old state, while components rendered after the pause will read the updated state. This UI inconsistency is known as tearing.

useSyncExternalStore forces React to treat updates from external stores with synchronous semantics, guaranteeing that every component in the render tree receives the exact same snapshot.

2. API Signature & Execution Flow

const snapshot = useSyncExternalStore<Snapshot>(
  subscribe: (onStoreChange: () => void) => () => void,
  getSnapshot: () => Snapshot,
  getServerSnapshot?: () => Snapshot
);

Key Execution Rules:

  1. subscribe: A function that accepts a single onStoreChange callback. When the external store updates, calling onStoreChange() informs React that a mutation occurred. It must return a cleanup function to unsubscribe.
  2. getSnapshot: Returns an immutable snapshot of the current state. It must return the exact same reference (===) if the state hasn’t changed.
  3. getServerSnapshot: Provides the initial snapshot used during Server-Side Rendering (SSR) and hydration.

3. Advanced Pattern 1: High-Performance Custom Selectors with Structural Sharing

A common mistake when implementing getSnapshot is returning a freshly constructed object or inline array filtering. Doing so breaks reference equality (===), causing React to trigger infinite re-render loops.

To extract slice data safely without extra re-renders, construct derived state using a memoized selector pattern:

import { useSyncExternalStore, useRef, useCallback } from 'react';

type StoreListener = () => void;

// Generic Observable Store
export class Store<T> {
  private state: T;
  private listeners = new Set<StoreListener>();

  constructor(initialState: T) {
    this.state = initialState;
  }

  get = (): T => this.state;

  set = (updater: (prev: T) => T): void => {
    this.state = updater(this.state);
    this.listeners.forEach((listener) => listener());
  };

  subscribe = (listener: StoreListener): (() => void) => {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  };
}

// Custom Hook: Custom Selector with Structural Memoization
export function useStoreSelector<T, SelectorResult>(
  store: Store<T>,
  selector: (state: T) => SelectorResult
): SelectorResult {
  const lastSelectedState = useRef<SelectorResult | null>(null);
  const lastState = useRef<T | null>(null);

  const getSnapshot = useCallback(() => {
    const currentState = store.get();

    // 1. Return cached result if raw store reference hasn't changed
    if (lastState.current === currentState && lastSelectedState.current !== null) {
      return lastSelectedState.current;
    }

    // 2. Derive new slice
    const nextSelectedState = selector(currentState);

    // 3. Perform basic shallow equality check on selected slice
    if (
      lastSelectedState.current !== null &&
      areShallowEqual(lastSelectedState.current, nextSelectedState)
    ) {
      return lastSelectedState.current; // Keep old reference to prevent re-render!
    }

    // 4. Update references
    lastState.current = currentState;
    lastSelectedState.current = nextSelectedState;
    return nextSelectedState;
  }, [store, selector]);

  return useSyncExternalStore(store.subscribe, getSnapshot);
}

// Utility: Shallow Equality Check
function areShallowEqual<T>(objA: T, objB: T): boolean {
  if (Object.is(objA, objB)) return true;
  if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
    return false;
  }

  const keysA = Object.keys(objA);
  const keysB = Object.keys(objB);

  if (keysA.length !== keysB.length) return false;

  return keysA.every((key) =>
    Object.prototype.hasOwnProperty.call(objB, key) &&
    (objA as Record<string, unknown>)[key] === (objB as Record<string, unknown>)[key]
  );
}

4. Advanced Pattern 2: Subscribing to Complex Web APIs (e.g., Network Status & Resize Observer)

useSyncExternalStore isn’t restricted to global state management libraries. It is ideal for binding React components directly to native browser APIs that emit imperative events.

Subscribing to Browser Online/Offline Status

import { useSyncExternalStore } from 'react';

function subscribeNetworkStatus(callback: () => void) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);

  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

function getNetworkSnapshot() {
  return navigator.onLine;
}

function getServerNetworkSnapshot() {
  return true; // Assume online on server side
}

export function useNetworkStatus(): boolean {
  return useSyncExternalStore(
    subscribeNetworkStatus,
    getNetworkSnapshot,
    getServerNetworkSnapshot
  );
}

Subscribing to Reactive Media Queries (matchMedia)

import { useSyncExternalStore, useCallback } from 'react';

export function useMediaQuery(query: string): boolean {
  const subscribe = useCallback(
    (callback: () => void) => {
      const matchMedia = window.matchMedia(query);
      matchMedia.addEventListener('change', callback);

      return () => matchMedia.removeEventListener('change', callback);
    },
    [query]
  );

  const getSnapshot = () => window.matchMedia(query).matches;
  const getServerSnapshot = () => false;

  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

5. Architectural Comparison: When to Use What

Criteria useState / useReducer useEffect + Local State useSyncExternalStore
Primary Target Internal React state Event-driven side effects External non-React stores & Browser APIs
Concurrent Safe? Yes No (Prone to layout flickers/tearing) Yes (Guarantees zero tearing)
SSR Support Native Requires hydration handling Requires getServerSnapshot
Rendering Semantics Async / Concurrent Async / Post-paint Synchronous for store updates