Trust is earned, not given

A different perspective

2023-09-26 · Projects

React.js, part 6: useSyncExternalStore — connecting React to the world outside it

Part 6from the React.js series · 20 parts in all

Sooner or later a component must render data that React does not own: a browser API, a WebSocket, a global store, matchMedia, navigator.onLine. The old answer — subscribe in an effect and mirror into state — has a well-known flaw: it can tear, and it renders one frame of stale data between subscribe and effect. React 18's answer is useSyncExternalStore.

The hook's contract

You give React three things: a subscribe function, a getSnapshot that returns the current value, and an optional server-side snapshot:

import { useSyncExternalStore } from 'react';

function useOnline() {
  return useSyncExternalStore(
    // subscribe: tell the store to notify us, return the unsubscribe
    (onChange) => {
      window.addEventListener('online', onChange);
      window.addEventListener('offline', onChange);
      return () => {
        window.removeEventListener('online', onChange);
        window.removeEventListener('offline', onChange);
      };
    },
    // getSnapshot: the CURRENT value - must be cheap and stable
    () => navigator.onLine,
    // getServerSnapshot: what the server should render
    () => true
  );
}

export function Status() {
  return <span>{useOnline() ? 'online' : 'offline'}</span>;
}

React subscribes during render — before paint — so there is no stale frame, and it detects store changes that happened during a concurrent render so two parts of the tree can never disagree. That last property is the "sync" in the name.

The one rule that bites people

getSnapshot must return a value that is referentially stable when nothing changed. Returning a fresh object every call makes React think the store changes on every render and loop forever. For an object store, cache the snapshot:

let cached = store.getState();
function getSnapshot() {
  return cached;   // same reference until the store actually changes
}
store.subscribe(() => { cached = store.getState(); });

When to reach for it

Not for ordinary app state — that is useState and reducers. It is for external state: third-party stores (Redux and Zustand both use this internally), browser globals, and subscriptions. With it, "subscribe in an effect" becomes a correctness smell rather than a default. Next: two quiet hooks — useDeferredValue and useId — that fix real, everyday bugs.