DEV Community

Cover image for Your Custom Hooks Tear in React 18. `useSyncExternalStore` Fixes It.
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

Your Custom Hooks Tear in React 18. `useSyncExternalStore` Fixes It.

The header says "You're offline." Three pixels below it, the save button is still clickable, sending requests like nothing's wrong. Same navigator.onLine value. Same render. Two different answers on screen at the same time.

You didn't write a new bug. You wrote the hook everyone writes.

function useOnlineStatus() {
  const [online, setOnline] = useState(navigator.onLine);

  useEffect(() => {
    const update = () => setOnline(navigator.onLine);
    window.addEventListener('online', update);
    window.addEventListener('offline', update);
    return () => {
      window.removeEventListener('online', update);
      window.removeEventListener('offline', update);
    };
  }, []);

  return online;
}
Enter fullscreen mode Exit fullscreen mode

It worked in every app you shipped before React 18. Then a teammate wraps a route transition in startTransition to keep the UI responsive, two components that both call useOnlineStatus() briefly disagree, and you spend an afternoon adding console.log statements to a bug that reproduces maybe one time in fifteen.

Why a perfectly normal hook starts lying

useState + useEffect assumes something React 18 no longer guarantees: that once a component commits, it stays committed until the next explicit update, and that every component re-renders from the same world.

Concurrent rendering breaks that assumption on purpose. React can start rendering a tree, pause partway through to handle something more urgent — a keystroke, a transition — and resume later. If the external value your hook reads (navigator.onLine, window.innerWidth, a value in localStorage, a WebSocket payload) changes in that gap, some components finish rendering with the old value and some with the new one. Nothing crashes. You just get a UI where two places disagree about one fact, for one frame, with no error to catch.

This has a name: tearing. It's not a React bug — it's the cost of letting renders be interruptible, and it only shows up once a render can be interrupted.

The hook was never wrong before — it was just never tested like this

Here's the part that catches people: useState + useEffect isn't a bad pattern. It's the correct pattern for state React owns. The problem is specifically external state — state that lives outside React and changes on its own schedule, which is exactly what "subscribe to a browser API" always is.

A useEffect-based subscription updates state after the store already changed, through a callback, on React's own timing. It has no way to ask, right before this component paints, "is this still current?" It just trusts whatever setOnline it last received.

The fix React shipped for exactly this

useSyncExternalStore is the hook React 18 added so you stop hand-rolling this. The shape is unusual the first time you see it — three functions, no state:

import { useSyncExternalStore } from 'react';

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

function getSnapshot() {
  return navigator.onLine;
}

function useOnlineStatus() {
  return useSyncExternalStore(subscribe, getSnapshot);
}
Enter fullscreen mode Exit fullscreen mode

subscribe wires up your listener once, same as the useEffect before. The difference is getSnapshot — a function that reads the current value directly, with nothing cached in React state. React calls it on every render, and critically, it re-checks it synchronously right before committing. If the store moved since the last check, React throws that render away and re-renders immediately with the fresh value, instead of letting a stale one paint. That synchronous re-check is the whole fix: it closes the exact gap where tearing happens.

You're not writing less code than before. You're handing React the one piece of information — "read it fresh, right now" — that a useState callback can never provide after the fact.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

The part that bites on the server

useSyncExternalStore takes an optional third argument, getServerSnapshot, and skipping it is the next trap:

function getServerSnapshot() {
  return true; // sensible default: assume online during SSR
}

useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
Enter fullscreen mode Exit fullscreen mode

There's no window on the server, so getSnapshot can't run there — navigator doesn't exist. If your component can render during SSR and you omit getServerSnapshot, React throws rather than guessing. It's not being pedantic: without it, the server-rendered HTML and the first client render would read different realities, and React would rather fail loudly than ship a hydration mismatch silently.

If your component is client-only (inside a dynamic import with SSR disabled, say), you can skip it. If it ever renders on the server, you can't.

You've already used this, you just didn't call it

If you reach for Zustand or react-redux (v8+), you're already running on useSyncExternalStore — it's what those libraries' React bindings switched to for exactly this reason. (Jotai, notably, chose not to — its author has written publicly about trading a small tearing risk for behavior that fits Suspense better.) You don't need to rewrite a working store library. You need this hook for the stores you wrote yourself: the useLocalStorage, useMediaQuery, or useWebSocketMessage hook sitting in your hooks/ folder, quietly subscribing to something outside React with a pattern that was correct until concurrent rendering gave it a seam to fall through.

🧠 Test yourself

Think it clicked? Take the 6-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

Go find your oldest useState + useEffect-plus-addEventListener hook. If it feeds more than one component, it has a tearing window — you just haven't hit the one render that exposes it yet. What's yours tracking: window size, online status, something else? Tell me in the comments and I'll tell you if it needs the swap.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (0)