useSyncExternalStore - The underrated React API

sebastienlorber

Sebastien Lorber

Posted on October 1, 2022

useSyncExternalStore - The underrated React API

useSyncExternalStore - The underrated React API

You might have heard of useSyncExternalStore(), a new React 18 hook to subscribe to external data sources. It is often used internally by state management libraries - like Redux - to implement a selector system.

But what about using useSyncExternalStore() in your own application code?

In this interactive article, I want to present you a problem: over-returning React hooks triggering useless re-renders. We will see how useSyncExternalStore() can be a good fix.

social card

💡 Pro tip: read the original article on ThisWeekInReact.com: it is interactive! 😜

Over-returning hooks

Let's illustrate the problem with useLocation() from React-Router.

This hook returns an object with many attributes (pathname, hash, search...), but you might not read all of them. Just calling the hook will trigger re-renders when any of these attributes is updated.

Let's consider this app:

function CurrentPathname() {
  const { pathname } = useLocation();
  return <div>{pathname}</div>;
}

function CurrentHash() {
  const { hash } = useLocation();
  return <div>{hash}</div>;
}

function Links() {
  return (
    <div>
      <Link to="#link1">#link1</Link>
      <Link to="#link2">#link2</Link>
      <Link to="#link3">#link3</Link>
    </div>
  );
}

function App() {
  return (
    <div>
      <CurrentPathname />
      <CurrentHash />
      <Links />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

app re-renders too often

On any hash link click, the CurrentPathname component will re-render, even if it's not even using the hash attribute 😅.

💡 Whenever a hook returns data that you don't display, think about React re-renders. If you don't pay attention, a tiny useLocation() call added at the top of a React tree could harm your app's performance.

ℹ️ The goal is not to criticize React-Router, but rather to illustrate the problem. useLocation() is just a good pragmatic candidate to create this interactive article. Your own React hooks and other third-party libraries might also over-return.

useSyncExternalStore to the rescue?

The official documentation says:

useSyncExternalStore is a hook recommended for reading and subscribing from external data sources in a way that’s compatible with concurrent rendering features like selective hydration and time slicing.
This method returns the value of the store and accepts three arguments:

  • subscribe: function to register a callback that is called whenever the store changes.
  • getSnapshot: function that returns the current value of the store.
  • getServerSnapshot: function that returns the snapshot used during server rendering.
function useSyncExternalStore<Snapshot>(
  subscribe: (onStoreChange: () => void) => () => void,
  getSnapshot: () => Snapshot,
  getServerSnapshot?: () => Snapshot
): Snapshot;
Enter fullscreen mode Exit fullscreen mode

This feels a bit abstract. This beta doc page gives a good example:

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

function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine,
    () => true
  );
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}
Enter fullscreen mode Exit fullscreen mode

It turns out that the browser history can also be considered as an external data source. Let's see how to use useSyncExternalStore with React-Router!

Implementing useHistorySelector()

React-Router expose everything we need to wire useSyncExternalStore:

⚠️ This website uses React-Router v5: the solution will be different for React-Router v6 (see).

The implementation of useHistorySelector() relatively simple:

function useHistorySelector(selector) {
  const history = useHistory();
  return useSyncExternalStore(history.listen, () =>
    selector(history)
  );
}
Enter fullscreen mode Exit fullscreen mode

Let's use it in our app:

function CurrentPathname() {
  const pathname = useHistorySelector(
    (history) => history.location.pathname
  );
  return <div>{pathname}</div>;
}

function CurrentHash() {
  const hash = useHistorySelector(
    (history) => history.location.hash
  );
  return <div>{hash}</div>;
}
Enter fullscreen mode Exit fullscreen mode

app re-renders as expected

Now, when you click on a hash link above, the CurrentPathname component will not re-render anymore!

Another example: scrollY

There are so many external data sources that we can subscribe to, and implementing your own selector system on top might enable you to optimize React re-renders.

For example, let's consider we want to use the scrollY position of a page. We can implement this custom React hook:

// A memoized constant fn prevents unsubscribe/resubscribe
// In practice it is not a big deal
function subscribe(onStoreChange) {
  global.window?.addEventListener("scroll", onStoreChange);
  return () =>
    global.window?.removeEventListener(
      "scroll",
      onStoreChange
    );
}

function useScrollY(selector = (id) => id) {
  return useSyncExternalStore(
    subscribe,
    () => selector(global.window?.scrollY),
    () => undefined
  );
}
Enter fullscreen mode Exit fullscreen mode

We can now use this hook with an optional selector:

function ScrollY() {
  const scrollY = useScrollY();
  return <div>{scrollY}</div>;
}

function ScrollYFloored() {
  const to = 100;
  const scrollYFloored = useScrollY((y) =>
    y ? Math.floor(y / to) * to : undefined
  );
  return <div>{scrollYFloored}</div>;
}
Enter fullscreen mode Exit fullscreen mode

scrollY not floored re-renders too often

Scroll the page and see how the components above re-render? One is re-rendering less than the other!

💡 When you don't need a scrollY 1 pixel precision level, returning a wide range value such as scrollY can also be considered as over-returning. Consider returning a narrower value.
For example: a useResponsiveBreakpoint() hook that only returns a limited set of values (small, medium or large) will be more optimized than a useViewportWidth() hook.
If a React component only handles large screens differently, you can create an even narrower useIsLargeScreen() hook returning a boolean.

Conclusion

I hope this article convinced you to take a second look at useSyncExternalStore(). I feel this hook is currently underused in the React ecosystem, and deserves a bit more attention. There are many external data sources that you can subscribe to.

If you still haven't upgraded to React 18, there's a npm use-sync-external-store shim that you can already use today in older versions. There is also a use-sync-external-store/with-selector export in case you need to return a memoized non-primitive value.


Subscribe to my newsletter This Week In React for more articles like this.

social card

💖 💪 🙅 🚩
sebastienlorber
Sebastien Lorber

Posted on October 1, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related