Your Custom Hooks Tear in React 18. useSyncExternalStore Fixes It.
A useState + useEffect hook that subscribes to window size, localStorage, or a WebSocket looks fine until React 18's concurrent rendering shows two parts of your UI two different values at once. useSyncExternalStore is the hook built to stop that.

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.
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.
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.
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.
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:
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.
Runs right in your browser — poke at it and watch the concept react live.
useSyncExternalStore takes an optional third argument, getServerSnapshot, and skipping it is the next trap:
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.
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.
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:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.