Back to overview
Concurrent Hydration with useSyncExternalStore

Concurrent Hydration with useSyncExternalStore

— by · 
Posted on: Oct 6, 2025

Sparked by recent interactions on social media1,2, it seems like more and more people adopt <Suspense>. Using Suspense correctly can greatly improve UX and performance (see my post about selective hydration), but when server-side-rendering (SSR), there are some non-obvious factors during hydration to be aware of. In this article, we’ll investigate what those are and how a Suspense-aware hook to avoid hydration mismatches looks like.

Table of contents

Open Table of contents

Avoiding hydration mismatches

One of my favorite blog posts regarding React hydration is “Avoiding Hydration Mismatches with useSyncExternalStore” by Dominik Dorfmeister, also known for TanStack. In the post, he shows how to write React components that avoid errors during hydration even if your server and client output is different:

function App() {
  const isSSR = React.useSyncExternalStore(
    // subscribe: do nothing and return an empty "unsubscribe" fn
    () => () => {},
    // getSnapshot: return false during client-side renders
    () => false,
    // getServerSnapshot: return true during SSR & hydration
    () => true
  )

  return isSSR ? "Server output" : "Client output"
}

The magic here is the third parameter of useSyncExternalStore: React uses the returned value during the hydration render pass. That means, as long as the third parameter returns the exactly same value as it does during SSR, it prevents hydration mismatches.

React then does another render pass right after, and reads from the 2nd parameter, the client snapshot. As a result, React renders the client output right away. What this also means is, we optimize UX, because users see the intended client output (think customized content) without further delay.

Compare it to the useEffect-way:

// This is inefficient. Don’t blindly copy.
function MyComp() {
  const [isSSR, setIsSSR] = useState(true)
  useEffect(() => {
    startTransition(() => setIsSSR(false))
  }, [])

  return isSSR ? "Server output" : "Client output"
}

React now also receives the server output during hydration. Then we trigger a re-render in the effect, leading to the client output becoming visible.

Do you see why this is more inefficient?

The answer is, during hydration, both approaches behave almost identical, but afterwards, it’s tricky to detect that hydration has ended. If MyComp remounts, it:

  1. Renders “Server output”, because the state is initialized with true
  2. Waits for browser paint before executing the effect
  3. Then re-renders with “Client output”

Dominik also explains this dilemma. In fact, setting state unconditionally in effects is a considered bad practice - see “You Might Not Need an Effect” and the fitting eslint plugin. useSyncExternalStore is better suited for this purpose, as you always know what is returned during SSR and hydration, and what is returned in all other renders. But…

🚧 Pitfalls of useSyncExternalStore

As hinted to in the intro, using useSyncExternalStore has some implications im combination with <Suspense>. The reason is, it triggers a non-concurrent synchronous (as the name implies) blocking render. This results in some UX issues:

1. Suspense boundaries flash their fallback.
Suspense fallbacks are usually loading states. Any loading spinner that appears unexpectedly makes the application not just feel slower than it really is (plus, rendering a loading spinner instead of the real content also takes time away from rendering the meaningful content asap).
Think this flow: Server output → Loading spinner → Client output. As a user, you see something, then it disappears briefly, and then something else comes back. This unecessary flash makes any app feel bad, sluggish and broken.

2. Updates to dehydrated children cause hydration errors.
If dehydrated children (those that have yet to be hydrated) receive unexpected updates, React throws a hydration error. This is non-trivial to debug, because it only happens if children have a <Suspense> boundary somewhere in their tree. Luckily, React can recover by client-side rendering the tree again - but that makes hydrating much more expensive and slower than needed, as React effectively needs to render all components again3.

3. Bad INP.
If you’ve read How to Improve INP: React, you know non-concurrent renders can negatively impact Core Web Vitals. Their synchronous nature blocks the main-thread, which means they might cause high INP when a user interacts with the website during the render. We only want non-concurrent renders when providing user feedback (e.g. in response to clicks). In all other cases, transitions & concurrent renders are the way to go, so that React’s scheduler yields every here and then (also called “time slicing” the render).

Here’s how renders triggered by useSyncExternalStore changes look like:

Screenshot of DevTools a 1 second blocking long task

Rendering 10 components happens in a single synchronous long task, triggered by the non-concurrent render from useSyncExternalStore. The main thread is blocked for the entire duration of the render. If a user clicks a link meanwhile, they would need to wait up to a second until something happens - again, very bad UX.

We can’t just live with those issues. Creating a great user experience should be our (any engineers) top priority.

✨ Concurrent useSyncExternalStore

We can do better: Making the useSyncExternalStore returned value concurrent. “Wait what, Jacob - uSES cannot be concurrent!” I hear you say. Buckle up. Expect a few “1) What” from your colleagues if you commit this:

const emptySubscribe = () => () => {}
const returnFalse = () => false
const trueOnServerOrHydration = () => true

function useIsSSR() {
  const isSSRSync = React.useSyncExternalStore(emptySubscribe, returnFalse, trueOnServerOrHydration)
  return React.useDeferredValue(isSSRSync) // ⬅️ This is the important part
}

function App() {
  const isSSR = useIsSSR()
  return isSSR ? "Server output" : "Client output"
}

How this works is, during the initial hydration render pass, useDeferredValue() returns isSSRSync=true, which means we render the server output. Then, useSyncExternalStore triggers a non-concurrent render pass with isSSRSync=false - but because we defer the value, our hook still returns isSSR=true. Only after, useDeferredValue() triggers the concurrent render that returns isSSR=false.

The result: We’ve successfully avoided a hydration mismatch, while being <Suspense>, UX and INP friendly.

Here’s how a sync useSyncExternalStore render (left) and how a ‘deferred’ concurrent render (right) looks:

Screenshot of DevTools showing time slicing

The left shows the same as above. On the right, the render is concurrent and React yields to the main thread frequently (I explain what ‘yield’ means here). This means, if a user interaction happens inbetween, we get great INP and React can prioritize that interaction accordingly. There is even no measurable overhead as the total duration stayed the same.

It’s important to note, useSyncExternalStore will always trigger a non-concurrent render pass, even with this technique. This means we should:

  1. Ensure what we return doesn’t change during that render pass
  2. Memoize Components

So if you use render props, or wrap other components, you additionally need useMemo (or React Compiler):

function useChildrenWithData(children) {
  const synchronousData = React.useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot)
  const deferredData = React.useDeferredValue(synchronousData)

  // The returned children only change when `deferredData` changes (in the concurrent render).
  // This makes the uSES call Suspense-friendly.
  return useMemoOne(() => children(deferredData), [deferredData, children])
}

function MyComp({ children }) {
  // Result:
  // - childrenWithData stays the same during the hydration and non-concurrent render pass
  // - New children are returned during the concurrent render
  const childrenWithData = useChildrenWithData(children)

  const isSSR = useIsSSR()
  const recommendations = useMemoOne(
    () => <Recommendations personalized={isSSR} />,
    [isSSR]
  )

  return (
    <>
      {childrenWithData}
      {recommendations}
    </>
  )
}

function App() {
  return (
    <React.Suspense>
      {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((value) => (
        <MyComp key={value}>
          {(data) => <div>render prop: {JSON.stringify(data)}</div>}
        </MyComp>
      ))}
    </React.Suspense>
  )
}

This ensures useChildrenWithData only returns new children when the concurrent non-blocking render happens (caused by useDeferredValue()), but not during the synchronous one (caused by useSyncExternalStore). Likewise, Recommendations only renders when isSSR changes.

Here’s a Codesandbox you can play with (thanks to Dominik for creating this):

We’ve been using this pattern at Framer for quite a while and it’s also approved by the React team. It’s just not known. Thankfully, the planned concurrent stores will make this much easier in the future.

If you’ve found this helpful, share a #TIL with your colleagues. Oh and … maybe the best time to get rid of some useEffects while improving performance with this-one-weird-hook is now :)

Footnotes & Comments

Thank you to Ivan Akulov for reviewing this post.

Footnotes

  1. Thread about Suspense, hydration and (non)-concurrent updates

  2. Thread about hydration & useSyncExternalStore

  3. If you have Suspense boundaries around, React tries to client-side render from the closest boundary, but that still means duplicated work.

  4. See also this React issue

  5. See also official React docs.


📖 All posts  · ⬆️ Back to top
Next Post
How To Improve INP: React⚛️