useSync­External­Store First Look

A first look at the useSyncExternalStore React hook, its rationale, its caveats and its non-library purposes.

Published · Last updated

useSyncExternalStore is one of the hooks introduced in React 18. Initially I assumed it was reserved for use in libraries It was advertised as a “library hook”, along with useInsertionEffect.

The following Hooks are provided for library authors to integrate libraries deeply into the React model, and are not typically used in application code.

And the changelog entry for it is similarly library-focused.

  • Add useSyncExternalStore to help external store libraries integrate with React

I don’t write libraries so I didn’t pay it any mind until I saw this tweet.

That got me curious, so I finally read the docs page and found there was a “Subscribing to a browser API” section in it all along. Better to discover it late than never I suppose. It starts off with this insight:

Another reason to add useSyncExternalStore is when you want to subscribe to some value exposed by the browser that changes over time.

Of course! I failed to realize that ‘external store’ doesn’t just mean ‘3rd party libraries’. The host environment — or more plainly, the browser — is an external store of state, state that we may want to consume in our React app. useSyncExternalStore can help us with that.

The million dollar question! Why do we bother? Why not stick to what we know? What’s wrong with the trusty useState & useEffect combo to read browser state?

The docs briefly mentions it but doesn’t really explain why:

[A browser API value] can change over time without React’s knowledge, so you need to read it with useSyncExternalStore

It has got to do with React 18's most notable new feature, or behind-the-scenes mechanism as the React team like to think of it, concurrent rendering.

With concurrent rendering, React maintains multiple versions of a UI simultaneously (concurrently), one visible and one work-in-progress. This allows the browser to handle events while rendering instead of being blocked, making the app feel more responsive.

React can do this with its built-in state APIs — useState and useReducer — but not for state that lives outside React, because only a single version of external state can be accessed at a time .

A consequence of this is that values in the external state used in render can change over time without React’s knowledge. This is the edge case that can cause the UI to tear; show two different values for the same data at the same time .

useSyncExternalStore is meant to mitigate that. It detects changes in external state during rendering and re-starts rendering before showing the inconsistent UI to the user . Because these updates are forced to be synchronous, React can guarantee that the UI will always be consistent .

So in brief, useSyncExternalStore helps to avoid inconsistency and staleness in your UI when dealing with subscriptions.

Next to that, it has some additional benefits like support for server-rendering, and it’s arguably easier in use.

I’m always big on examples and use cases, how might useSyncExternalStore be used in apps? I tried my hand at rewriting two browser-syncing hooks I regularly use to make use of this new hook.

useMediaQuery is a hook to monitor media queries — yes the CSS ones — in JS-land, for example to grab user preferences like prefers-color-scheme.

[R]eading from a stateful DOM API requires setting up a subscription. This isn't even specific to React; that's how other frameworks work, too. So one definition of a "mutable source" [external store] is anything that requires a subscription to notify React of changes. For example, a canonical example is a useMediaQuery hook that subscribes to a MediaQueryList change event.

Here’s what I came up with


type MediaQuery = `(${string}:${string})`;
function getSnapshot(query: MediaQuery) {
return window.matchMedia(query).matches;
}
function subscribe(onChange: () => void, query: MediaQuery) {
const mql = window.matchMedia(query);
mql.addEventListener("change", onChange);
return () => {
mql.removeEventListener("change", onChange);
};
}
export function useMediaQuery(query: MediaQuery) {
const subscribeMediaQuery = React.useCallback((onChange: () => void) => {
subscribe(onChange, query)
}, [query])
const matches = React.useSyncExternalStore(
subscribeMediaQuery,
() => getSnapshot(query),
);
return matches;
}

Or view it on CodeSandbox.

Note that because query is used in subscribeMediaQuery, this function has to live inside useMediaQuery, making the function reference change with each call.

React will resubscribe every time you pass a different subscribe function between re-renders , which may lead to performance issues.

To only resubscribe when query changes, you need to wrap subscribeMediaQuery in useCallback and put query in its dependency array.

Another common browser-syncing hook with a self-explanatory name. Here’s my stab at it.


function onWindowSizeChange(onChange: () => void) {
window.addEventListener("resize", onChange);
return () => window.removeEventListener("resize", onChange);
}
function getWindowWidthSnapshot() {
return window.innerWidth;
}
function getWindowHeightSnapshot() {
return window.innerHeight;
}
function useWindowSize({ widthSelector, heightSelector }) {
const windowWidth = useSyncExternalStore(
onWindowSizeChange,
getWindowWidthSnapshot
);
const windowHeight = useSyncExternalStore(
onWindowSizeChange,
getWindowHeightSnapshot
);
return { width: windowWidth, height: windowHeight };
}

At first I tried returning the object directly from a single useSyncExternalStore like this:


function getWindowSizeSnapshot() {
// 💥
return {
width: window.innerHeight,
height: window.innerHeight
};
}

And it blew up with a “too many rerenders” error. I had missed a very important caveat!

The value returned by getSnapshot must be immutable . That means no returning array or object literals in it!

Either I could fix it it by adding memoization or just separating the height and the width. Separating feels simpler so I opted for that.

This seems like a common-enough pitfall that it’d be nice to have an ESLint rule for, though I think it might be hard to write. Which reminds me I should really learn how to write ESLint rules, I have a list of ideas.

Avoiding Rerenders With a Selector Function

Sébastian wrote a great post on this hook, useSyncExternalStore - The underrated React API, he argues it’s an underutilized hook. One of the main benefits it that it easily supports selector functions.

A selector reads the state as an argument, and returns data based on that state . By passing a selector function to getSnapshot, the number of updates can be limited.

Let’s apply the same optimization here. Maybe you don’t need to need to the window size down to the pixel. This hook will trigger a render for each pixel anyway, it is over-returning. Say you only care about every 100px of change in width:


const widthStep = 100; // px
const widthSelector = (w: number) => (w ? Math.floor(w / widthStep) * widthStep : 1)
function windowWidthSnapshot(selector = (w: number) => w) {
return selector(window.innerWidth);
}
function App() {
const width = useSyncExternalStore(onWindowSizeChange, () =>
windowWidthSnapshot(widthSelector)
);
...
}

View the live code on CodeSandbox.

useSyncExternalStore secret superpower is its third, optional argument getServerSnapshot. It’s a function that returns the initial snapshot used only during server rendering and during hydration , so you can avoid rehydration perils.

There are two things to look out for with getServerSnapshot.

  1. It must be defined if useSyncExternalStore is used on the server, an error will be thrown if it isn’t .
  2. Its output must be the same as on the client as it is on the server, .

What about SSR for the browser-state reading hooks like the ones shown above? It simply won’t work, information on window is only available on the client . There’s no way to provide an initial value without it being a .

So now what?

The React docs recommends to simply not render such components on the server .

[getServerSnapshot] lets you provide the initial snapshot value which will be used before the app becomes interactive.

If there is no meaningful initial value for the server rendering, you can force the component to render only on the client.

Which is something that never occurred to me. The news docs really keep on giving! I highly suggest you read that linked section. I’ll put a summary here for completeness and your convenience.

Convenient Summary

If a component throws an error on the server, React will not abort the server render. Instead, it will find the closest <Suspense> component above it and include its fallback (such as a spinner) into the generated server HTML.

On the client, React will attempt to render the same component again. If it does not error on the client, React will not display the error.

You can use this to opt out components from rendering on the server. To do this, throw an error from them in the server environment and then wrap them in a <Suspense> boundary to replace their HTML with fallbacks:

And in my own words: There’s no point in server-rendering a component that requires client-only info. Instead, leave a hole in the component tree on the server by throwing an error, then pass it to the client and let it fill the hole.

Questionable Advice

Well you asked for it. Here’s the dodgy option, it’s the “let’s try and hope for the best” approach.

React provides an escape hatch to suppress unavoidable hydration mismatches with the suppressHydrationWarning prop for elements . It only works for attributes and text content.

Mismatches should be viewed as bug and React does not guarantee it will patch up the values correctly , use it at your own discretion. Also, if the user has disabled JavaScript, there’s no possibility for React to correct the values.

I tried it for the useWindowSize hook and it appeared to be fine.


function Canvas() {
const windowWidth = useSyncExternalStore(
onWindowSizeChange,
() => window.innerWidth,
() => 1200 // ⚠️ Fudge alert!
);
return <canvas width={windowWidth} suppressHydrationWarning />
}

I hope that demystifies the “what” and “why” of useSyncExternalStore a bit.

  • useSyncExternalStore is mostly but not only for libraries.
    • It’s for subscribing to external state but in a broader sense than I thought.
    • The browser is an external store that you may want to sync with in your React app
    • It’s concurrent-safe so visual inconsistencies in UI are avoided.
  • If the subscribe function parameter is not stable React will resubscribe to the store every render.
  • The getSnapshot function parameter must return immutable values.
  • Its optional third function parameter getServerSnapshot is to support SSR
    • It must return the same exact data on the initial client render as on the server, meaning you can’t read browser API’s on the server.
    • If you can’t provide an initial value on the server, make the component client-only by throwing an error on the server and wrapping it in a <Suspense> boundary to display a fallback.

  1. An exception to these rules is that it is ok to read and mutate a value if it is for the purpose of lazy initialization. By that we mean that you only read to check if a property/binding is initialized, if not initialize it, write the result to the property/binding and from that point always return the same value.
    The Rules of React — Lazy Initialization
  2. The reason is that startTransition relies on the ability to maintain multiple versions of a UI concurrently: the current UI that’s visible on screen, and a work-in-progress UI that is prepared in the background while data progressively streams in. React can do this with its built-in state APIs — useState and useReducer — but not for state that lives outside React, because we only can access a single version of state at a time.
    [...]
    Updates triggered by a store change will always be synchronous, even when wrapped in startTransition
    React Working Group: useMutableSource → useSyncExternalStore — Concurrent reads, synchronous updates
    2
  3. Tearing is a term traditionally used in graphics programming to refer to a visual inconsistency. For example, in a video, screen tearing is when you see multiple frames in a single screen, which makes the video look “glitchy”. In a user interface, by “tearing” we mean that a UI has shown multiple values for the same state. For example, you may show different prices for the same item in a list, or you submit a form for the wrong price, or even crash when accessing outdated store values.
    Since JavaScript is single threaded, this issue generally has not come up in web development. But in React 18, concurrent rendering makes this issue possible because React yields during rendering. This means that when using a concurrent feature like startTransition or Suspense, React can pause to let other work happen. Between these pauses, updates can sneak in that change the data being used to render, which can cause the UI to show two different values for the same data.
    This problem isn’t specific to React, it’s a necessary consequence of concurrency. If you want to be able to interrupt rendering to respond to user input for more responsive experiences, you need to be resilient to the data you’re rendering changing and causing the user interface to tear.
    React Working Group: React 18 — What is tearing?
  4. With external state, instead of scheduling updates to a queue that can be processed in the correct order, the state can be mutated directly in the middle of rendering. So to support an external store, you need some way to either 1) tell React that the store updated during render so React can re-render again 2) force React to interrupt and re-render when the external state changes or 3) implement some other solution that allows React to render without state changing in the middle of renders.
    Concurrent React for Library Maintainers #70
    2
  5. useSyncExternalStore is a new hook that allows external stores to support concurrent reads by forcing updates to the store to be synchronous. It removes the need for useEffect when implementing subscriptions to external data sources, and is recommended for any library that integrates with state external to React.
    React Blog v18.0 — useSyncExternalStore
  6. This subscribe function is defined inside a component so it is different on every re-render: React will resubscribe to your store if you pass a different subscribe function between re-renders. If this causes performance issues and you’d like to avoid resubscribing to the store, move the subscribe function outside: Alternatively, wrap subscribe into useCallback to only resubscribe when some argument changes.
    React Docs: useSyncExternalStore — My subscribe function gets called after every re-render
  7. The store snapshot returned by getSnapshot must be immutable. If the underlying store has mutable data, return a new immutable snapshot if the data has changed. Otherwise, return a cached last snapshot.
    React Docs: useSyncExternalStore — Caveats
  8. A "selector function" is any function that accepts the Redux store state (or part of the state) as an argument, and returns data that is based on that state. [...] One common description of selectors is that they're like "queries into your state". You don't care about exactly how the query came up with the data you needed, just that you asked for the data and got back a result.
    Redux docs: Basic Selector Concepts
  9. A function that returns the initial snapshot of the data in the store. It will be used only during server rendering and during hydration of server-rendered content on the client. The server snapshot must be the same between the client and the server, and is usually serialized and passed from the server to the client. If this function is not provided, rendering the component on the server will throw an error
    React Docs: useSyncExternalStore - Adding support for server rendering
    2
  10. The React tree you pass to hydrateRoot needs to produce the same output as it did on the server. This is important for the user experience. The user will spend some time looking at the server-generated HTML before your JavaScript code loads. Server rendering creates an illusion that the app loads faster by showing the HTML snapshot of its output. Suddenly showing different content breaks that illusion. This is why the server render output must match the initial render output on the client during hydration.
    React Docs: hydrateRoot — Pitfall
  11. If you’re connecting to a browser-only API, it won’t work because it does not exist on the server.
    React docs: useSyncExternalStore - Adding support for server rendering
  12. you can’t avoid UI changing in principle. not because of something in React, but because the browser first shows HTML (which was generated on the server without access to localStorage). how to make that UI change most pleasant depends on the exact scenario.
    I think you might be overfocusing on the consequence (React warns about a mismatch), but the actual root of the problem has nothing to do with React. it’s that if your UI really depends on localStorage value, there’s no way for it to be the same on the client and the server.
    the most common way to resolve this is to render in two passes. both server render and the hydrating client render show some kind of placeholder content. then another client re-render takes localStorage into account.you can useSyncExternalStore for that.
    Tweet reply by Dan Abramov
    2
  13. The most common causes leading to hydration errors include: [...] Using browser-only APIs like window.matchMedia in your rendering logic.
    React docs: hydrateRoot – Pitfall
  14. If a single element’s attribute or text content is unavoidably different between the server and the client (for example, a timestamp), you may silence the hydration mismatch warning. To silence hydration warnings on an element, add suppressHydrationWarning={true}
    React docs: hydrateRoot - Suppressing unavoidable hydration mismatch errors
  15. There are no guarantees that attribute differences will be patched up in case of mismatches. This is important for performance reasons because in most apps, mismatches are rare, and so validating all markup would be prohibitively expensive.
    React docs: hydrateRoot — Caveats