Techtales.

Exploring useSyncExternalStore, a Lesser Known React Hook

If you've ever built a custom hook that subscribes to a browser API, a global event emitter, or a hand-rolled store, you've probably reached for useState and useEffect. It works — until it doesn't. React 18 introduced useSyncExternalStore, a hook specifically designed to solve the subtle but real problems that come with subscribing to data that lives outside of React's state system.

In this post, we'll break down what the hook does, why it exists, and how to use it confidently — with practical examples you can adapt right away.

What Is an External Store?

An external store is any source of data that React doesn't manage. That includes:

  • Browser APIs like navigator.onLine, window.innerWidth, or localStorage
  • WebSocket connections
  • Global JavaScript variables or singletons
  • Third-party state managers like Redux or Zustand
  • Custom event emitters

These stores change on their own schedule, outside of any React component lifecycle. The challenge is reading from them safely and keeping your UI in sync without bugs.

The Hook Signature

Here's what each parameter does:

  • subscribe(callback) — A function that subscribes to the store. React passes in a callback, and you must call it whenever the store changes. It must return a cleanup/unsubscribe function.
  • getSnapshot() — A function that returns the current value of the store. React will call this to read the data. Critically, it must return the same value if nothing has changed.
  • getServerSnapshot() — Optional. Used during server-side rendering (SSR) to provide an initial value before the store is available on the client.

The Problem: Subscribing to External Data the Old Way

Before useSyncExternalStore, the standard approach was to combine useState and useEffect. Here's a typical example — a hook that tracks whether the user is online:

This looks reasonable, and for many cases it works fine. But there are real problems lurking here:

  • Render tearing: With React 18's concurrent rendering, React can pause and resume renders. If the external store updates mid-render, different parts of your UI could read different values from the same store — a phenomenon called tearing.
  • Timing issues: The initial value is read synchronously (navigator.onLine in useState), but the subscription is set up asynchronously inside useEffect. There's a brief window where a change could be missed.
  • Boilerplate creep: Every external subscription needs this same pattern. It adds up quickly and is easy to get subtly wrong.
  • Not concurrent-safe: React's concurrent features (like startTransition and Suspense) assume state reads are consistent throughout a render pass. The useEffect pattern doesn't guarantee this.

The Solution: Rewriting with useSyncExternalStore

Here's the same online status hook, rewritten the right way:

Let's walk through what's happening:

  • subscribe registers React's internal callback on the online and offline events. Whenever either fires, React is notified. The returned function cleans up when the component unmounts.
  • getSnapshot synchronously reads the current value. React calls this both during the render and after the store updates to determine whether a re-render is needed.
  • Because navigator.onLine is a primitive (true or false), snapshot stability is guaranteed — the same value returns the same result, so React won't re-render unnecessarily.

Notice there's no useEffect, no useState, and no cleanup logic scattered around your component. The hook handles everything declaratively.

How It Works Internally

You don't need to know React's internals to use useSyncExternalStore, but understanding the model helps you use it correctly.

React's process looks roughly like this:

  1. On mount, React calls subscribe, passing in its own internal re-render callback.
  2. React calls getSnapshot() to read the current value and store it.
  3. When the external store changes, it calls React's callback.
  4. React calls getSnapshot() again. If the value has changed (compared via Object.is), it schedules a synchronous re-render.
  5. The re-render happens synchronously — this is the "sync" in useSyncExternalStore. It bypasses React's concurrent scheduling to ensure the UI is never caught reading stale or inconsistent data.

This synchronous guarantee is what prevents tearing. During a concurrent render, React can check the snapshot mid-flight and bail out if something has changed, ensuring every part of the UI sees the same version of the store.

Building a Simple Global Store

One of the most powerful applications of useSyncExternalStore is building lightweight global state without any library. Here's a minimal counter store:

And here's how you'd use it in a component:

Any number of Counter components — anywhere in the tree — will stay in sync automatically. No context, no prop drilling, no Redux. The store lives entirely outside React, and useSyncExternalStore bridges the gap safely.

When Should You Use It?

useSyncExternalStore shines when you need to subscribe to data that changes outside of React. Good use cases include:

  • Browser APIs: navigator.onLine, window.matchMedia, scroll position, window dimensions
  • WebSocket state: connection status, incoming messages
  • localStorage synchronization: keeping multiple tabs in sync via the storage event
  • Custom global stores: lightweight shared state without a library
  • Library authors: if you're building a state management library for React 18+, this is the correct integration point

You probably don't need it for local component state, derived state, or anything already managed by React's own state primitives.

Why Not Just Use React Query, Zustand, or Redux?

Fair question. Here's the honest answer: those libraries exist at a different level of abstraction, and they're not in competition with useSyncExternalStore.

ToolPurposeUses useSyncExternalStore?
React QueryServer state: fetching, caching, revalidationNot directly — different model
ZustandClient-side global state managementYes, internally
ReduxPredictable client state with devtoolsYes, via react-redux v8+
useSyncExternalStoreLow-level primitive for store subscriptionsIt is the primitive

React Query is purpose-built for server state — it handles async fetching, background revalidation, caching, and pagination. It's the right tool when your data comes from an API.

Zustand and Redux are client state managers. They handle complex state logic, middleware, and devtools. Crucially, both of them use useSyncExternalStore under the hood to safely connect their stores to React's rendering system.

useSyncExternalStore is the foundation. Think of it as the low-level primitive that library authors use — and that you can use directly when you need something lightweight and custom.

SSR Support with getServerSnapshot

If your app uses server-side rendering (e.g., with Next.js), you'll need the third parameter. The problem is that browser APIs like navigator.onLine or localStorage don't exist on the server. Without a fallback, your server render will throw or produce incorrect output.

The getServerSnapshot function is called on the server (and during React's hydration pass on the client). It must return a consistent value that matches what the server rendered — otherwise you'll get a hydration mismatch error.

A practical rule: return a safe, sensible default (like true for online status, or an empty initial state for a store) that won't cause the server HTML and client HTML to diverge.

Common Mistakes to Avoid

1. Returning New Objects from getSnapshot

This is the most common pitfall. React uses Object.is to compare snapshot values. If getSnapshot returns a new object reference on every call — even with the same data — React will re-render infinitely.

2. Using Unstable subscribe Functions

Defining subscribe inside a component causes React to re-subscribe on every render, which is wasteful and can cause bugs. Always define it outside the component, or stabilize it with useCallback.

3. Forgetting to Return the Unsubscribe Function

The subscribe function must return a cleanup function. Forgetting this causes memory leaks — listeners pile up every time the component mounts.

4. Using It for Simple Local State

If your data only belongs to one component and doesn't need to be shared, use useState. useSyncExternalStore is for external, shared, or non-React-managed data.

Best Practices

  • Keep snapshots stable. Return primitives where possible. If you must return objects, memoize them so React doesn't over-render.
  • Define subscribe and getSnapshot at module scope. This keeps them stable across renders and makes your store logic easier to test in isolation.
  • Wrap everything in a custom hook. Don't call useSyncExternalStore directly in components. Abstract it behind a named hook like useOnlineStatus or useWindowWidth for reusability and readability.
  • Always handle SSR. Even if you're not using Next.js today, providing a getServerSnapshot is good defensive coding and makes your hook portable.
  • Isolate store mutation logic. Keep your store's state, listeners, and mutation functions together in a single module, separate from your React code.

Conclusion

useSyncExternalStore fills a specific and important gap in React's hook API. It's the right way to subscribe to data that lives outside of React — browser APIs, global stores, WebSockets, or anything else that changes on its own schedule.

It solves real problems that the useEffect + useState pattern quietly suffers from: render tearing, timing gaps, and concurrent rendering incompatibility. And it does so with a clean, explicit API that makes your subscription logic easy to reason about.

You don't need it for everything. For local component state, useState is still the answer. For server data, React Query is still the better tool. But the next time you find yourself writing useEffect(() => { store.subscribe(...) }, []), pause — useSyncExternalStore is almost certainly the cleaner, safer, more correct approach.

The key takeaway: treat useSyncExternalStore as the official bridge between the non-React world and React's rendering system. Once you internalize that mental model, you'll know exactly when to reach for it.

0
0