Introduction to React v18's useSyncExternalStore hook

In this lesson, we are going to explore how the newly added useSyncExternalStore hook works and how it simplifies reactivity with external data stores outside React components.

Before we dive deep into how the useSyncExternalStore React hook works, let’s explore a simple example of reactivity in React. Let’s say we want to design a simple React application that displays the current state of internet connectivity in the user’s device. We can use the navigator.[onLine](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine) property from the browser’s Web API to get this state and use online and offline events on window to listen to this state change.

import { useEffect, useState } from "react";

export default function App() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  useEffect(() => {
    const onlineCallback = () => setIsOnline(true);
    const offlineCallback = () => setIsOnline(false);

    window.addEventListener("online", onlineCallback);
    window.addEventListener("offline", offlineCallback);

    return () => {
      window.removeEventListener("online", onlineCallback);
      window.removeEventListener("offline", offlineCallback);
    };
  }, []);

  return (
    <div className="App">
      <h1>Am I online?</h1>
      <h2>{isOnline ? "Yes" : "No"}</h2>
    </div>
  );
}

In the example above, we have isOnline state that changes based on online and offline event. Based on this state, we display a message whether the user is online or offline as shown below.

The important takeaway here is that navigator.onLine value is external to our React component and to synchronize it with our React component, we need to use useState to hold its value and useEffect to subscribe to its changes so that its value can be properly synchronized.

Can we make it better? Yes we can and that’s why React team has added a new hook useSyncExternalStore React hook to React 18. Instead of going through its API first, let’s see how it works with our previous application.

import { useSyncExternalStore } from "react";

export default function App() {
  const isOnline = useSyncExternalStore(
    (callback) => {
      console.log("subscribed");

      window.addEventListener("online", callback);
      window.addEventListener("offline", callback);

      return () => {
        console.log("unsubscribed");
        window.removeEventListener("online", callback);
        window.removeEventListener("offline", callback);
      };
    },
    () => {
      console.log("value returned");
      return navigator.onLine;
    },
  );

  console.log("rendering");

  return (
    <div className="App">
      <h1>Am I online?</h1>
      <h2>{isOnline ? "Yes" : "No"}</h2>
    </div>
  );
}

In the above example, we have implemented the useSyncExternalStore hook and it returns us a value. We have given this hook two arguments of type function. In reality, this hook accepts three function arguments but we will talk about the third argument later as it’s optional.

const snapshot = useSyncExternalStore(
  subscribe,
  getSnapshot,
  getServerSnapshot?
)

Let’s first talk about what a store is. A store is a value or collection of values (such as an Array or a Object) that can change based on some side effects in the application (such as user input or network request). Our React component might want to subscribe to the change in this value and consume it to render something on the screen.

In the above example, you can call navigator a store that holds network status value in the onLine property (_or you can even call _*navigator.onLine *a store with a boolean value). Whenever the internet connection to the device change, the value of onLine property changes. However, our React application has no way to know when this value changes hence without rendering the component we won’t be able to reflect its current value. In the previous example, we are rendering the true value of navigator.onLine using the useState hook and setting a new value using online and offline event handlers to trigger a new render.

💡 So we could have gotten away with just using the navigator.onLine such as {navigator.onLine ? "Yes" : "No"} expression instead of {isOnline ? "Yes" : "No"} and but that would have left online state value useless. However, it doesn’t make any difference in the functionality of the component.

The first argument to the useSyncExternalStore is a function that will be invoked after the first render of the component with a callback function argument. The callback function should be invoked whenever there is a change in the store. And this function should return a function that will be invoked when the component unmounts (_just like _useEffect) to clean up any subscriptions (such as event handlers).

This first argument is called subscribe because it subscribes to the change in the store. It utilizes the callback function passed to it during its invocation to notify React that the value in the store has changed and it’s time to rerender the component. We used online and offline events to subscribe to navigator store and invoked callback whenever these events are fired. The return function is also useful because when the component unmounts, we can remove event listeners for these events. Remember here that we are not passing any values to the callback function. The callback only acts as a way to notify React that the store has changed.

The second function argument provides a way for React to get the value from the store. This function must return a value from the store. This is why this argument is called getSnapshot since it’s returning the state of the store (or substate) at a given point in time, hence the snapshot. When the component renders, useSyncExternalStore gets this return value and returns as its return value which we then can consume in the component. Whenever the callback function invokes, useSyncExternalStore hook calls the getSnapshot function to fetch a new snapshot, and then the component rerenders. Let’s look at the logs of the example above.

As you can see from the logs when the component started rendering, the useSyncExternalStore called getSnapshot function to get the current snapshot of the store (and cached it). Then the rendering started (so that our React component renders the latest value). After that, it called subscribe function to subscribe to the store changes. React also called getSnapshot once more after rendering just to make sure that store hasn’t changed after the rendering. If this new value is different than the last render, the component will be re-rendered.

When we changed the network state in the above example, useSyncExternalStore knew the store has changed since the callback was invoked, it fetched the new snapshot from getSnapshot function and the component started rendering again. It called getSnapshot once more after that just to make sure the store hasn’t changed.

💡 If you are wondering why you are seeing “value returned” logs multiple times here, it’s seems React internally calls getSnapshot multiple times to check if the new value returned by the getSnapshot is different than the old (cached) one.

But you might be confused as to why useSyncExternalStore unsubscribed and then subscribed to the store again as we can see “unsubscribed” and “subscribed” logs. This happens because the subscribe function passed to useSyncExternalStore hook is different on every render. When that happens, React will unsubscribe to the store using the cleanup function returned from the old subscribe function and resubscribe to the store using the newly passed subscribe function.

To avoid this, we can move the subscribe function outside our react component or memoize it using useCallback in case the subscribe function needs additional information from the component.

import { useSyncExternalStore } from "react";

const subscribe = (callback: () => void) => {
  console.log("subscribed");

  window.addEventListener("online", callback);
  window.addEventListener("offline", callback);

  return () => {
    console.log("unsubscribed");
    window.removeEventListener("online", callback);
    window.removeEventListener("offline", callback);
  };
};

export default function App() {
  const isOnline = useSyncExternalStore(subscribe, () => {
    console.log("value returned");
    return navigator.onLine;
  });

  console.log("rendering");

  return (
    <div className="App">
      <h1>Am I online?</h1>
      <h2>{isOnline ? "Yes" : "No"}</h2>
    </div>
  );
}

In the above example, we have moved the subscribe function outside our React component hence its value should be the same on every render. Now if we look at the logs, React should not resubscribe to the store unnecessarily.

One important point to remember here is that the getSnapshot function must return the same value on multiple renders if the underlying store data has not changed. React under the hood uses Object.[is](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is)() to compare the new snapshot with the older (cached)one. Let’s see an example of how Object.is() works.

let x = { a: 1 };
let y = { a: 1 };
let z = x;

console.log(Object.is(x, y)); // false
console.log(Object.is(x, z)); // true

x = { a: 2 };
console.log(x); // {a: 2}
console.log(z); // {a: 1}
console.log(Object.is(x, z)); // false

The Object.is(a, b) compares a and b for using the same-value equality principle. Same-value equality determines whether two values are functionally identical in all contexts. In the above case, though x and y value is the same, they are not identical, as in the change in the value of x will not affect the value of y. While z = x does not provide z a copy of x but it’s the value of x itself such that mutation in the value of x will affect z. When x is replaced with a new value, its value is now held at a new memory location while z retains its old value.

If the value returned by the getSnapshot is not functionally identical to the value cached by React on the previous getSnapshot() call, React component will rerender with the latest value. Since the new render will trigger another getSnapshot call to make sure the store has not changed, React component might go into an infinite render cycle if getSnapshot returns a new value on every render. Let’s see what this might look like by implementing a custom store.

import { useSyncExternalStore } from "react";

const store = {
  isOnline: true
};

const subscribe = (callback: () => void) => {
  console.log("subscribed");

  const setOnline = () => {
    store.isOnline = true;
    callback();
  };

  const setOffline = () => {
    store.isOnline = false;
    callback();
  };

  window.addEventListener("online", setOnline);
  window.addEventListener("offline", setOffline);

  return () => {
    console.log("unsubscribed");
    window.removeEventListener("online", setOnline);
    window.removeEventListener("offline", setOffline);
  };
};

export default function App() {
  const value = useSyncExternalStore(subscribe, () => {
    console.log("value returned");
    return {
      isOnline: store.isOnline
    };
  });

  console.log("rendering");

  return (
    <div className="App">
      <h1>Am I online?</h1>
      <h2>{value.isOnline ? "Yes" : "No"}</h2>
    </div>
  );
}

In the above example, we have implemented a custom store with the name store which is a simple object with isOnline property. The default value here is true (_but can also be read from _navigator.onLine). In the subscribe function, we defined two event handlers setOnline and setOffline for online and offline events respectively. Inside these handlers, we are updating the store and also invoking callback to notify React that the store has been updated.

Notice here that getSnapshot function returns a new object with isOnline the property which has the value of store.onLine. This doesn’t look problematic in the beginning but see below what happens with the application.

As we can verify from the logs, React component has gone into an infinite rendering cycle. Since getSnapshot function returns a new value on its invocation that is always different from the last cached value (_as compared by _*Object.is()*), even when the store has not been changed, React keeps calling getSnapshot until the new value is identical to the cached value but sadly it never happens in our case. The way to fix this is to return the store (or part of the store) itself so that returned value points to the same object.

import { useSyncExternalStore } from "react";

const store = {
  isOnline: true
};

const subscribe = (callback: () => void) => {
  console.log("subscribed");

  const setOnline = () => {
    store.isOnline = true;
    callback();
  };

  const setOffline = () => {
    store.isOnline = false;
    callback();
  };

  window.addEventListener("online", setOnline);
  window.addEventListener("offline", setOffline);

  return () => {
    console.log("unsubscribed");
    window.removeEventListener("online", setOnline);
    window.removeEventListener("offline", setOffline);
  };
};

export default function App() {
  const value = useSyncExternalStore(subscribe, () => {
    console.log("value returned");
    return store;
  });

  console.log("rendering");

  return (
    <div className="App">
      <h1>Am I online?</h1>
      <h2>{value.isOnline ? "Yes" : "No"}</h2>
    </div>
  );
}

In the above modification, we are returning to the store directly from the getSnapshot function. Let’s see if this fixes the problem.

Oops! Looks like even after changing the network state to offline, our React component is not rendering. We can only see one getSnapshot call was made by React, which indicates that callback was called but React ignored the update. Why this might happen?

In the subscribe function, we are updating the store by mutating it. When getSnapshot function is called by React, it caches its return value as is (_just like _*z = x*), and not copies by any means. In our case, this return value is store itself. Hence when we mutate store , the cached value is also mutated implicitly. Therefore, when getSnapshot is invoked after this mutation, React does not see any difference between the new value and the cached value. Hence value returned by getSnapshot must be immutable. In practice, we should keep our entire store immutable.

We can fix this issue by replacing the entire store with the new value. This way, the old snapshot is not affected by this change while the new snapshot will return a different value.

import { useSyncExternalStore } from "react";

let store = {
  isOnline: true
};

const subscribe = (callback: () => void) => {
  console.log("subscribed");

  const setOnline = () => {
    store = { isOnline: true };
    callback();
  };

  const setOffline = () => {
    store = { isOnline: false };
    callback();
  };

  window.addEventListener("online", setOnline);
  window.addEventListener("offline", setOffline);

  return () => {
    console.log("unsubscribed");
    window.removeEventListener("online", setOnline);
    window.removeEventListener("offline", setOffline);
  };
};

export default function App() {
  const value = useSyncExternalStore(subscribe, () => {
    console.log("value returned");
    return store;
  });

  console.log("rendering");

  return (
    <div className="App">
      <h1>Am I online?</h1>
      <h2>{value.isOnline ? "Yes" : "No"}</h2>
    </div>
  );
}

In the above modification, instead of mutating the store with store.isOnline = false;, we are now completely replacing the store with the new value using store = { isOnline: true };.

As you can see, the application works fine as intended because any change in the store does not change the cached value React gets from the getSnapshot function.

Now let’s come to the final piece of the puzzle. The third argument of useSyncExternalStore hook. As we briefly talked about, it’s an optional argument of type function but what’s its use? This function is not invoked on the client side but rather on the server side. When React component with useSyncExternalStore hook is rendered on the server side, we also need a value returned by it so that our React component can provide correct HTML in the SSR environment.

Just like getSnapshot, this third function should also return a snapshot of the store but on the server. Hence it’s called getServerSnapshot. If this function is missing and the component is rendered on the server side, it will throw an error. All the rules of the getSnapshot function apply to the getServerSnapshot function. The important point to remember is that the value returned by the getServerSnapshot function should be similar to the first value returned by the getSnapshot function so that there is no mismatch between HTML rendered by the server and the client.

That’s it, folks. This was the introduction to useSyncExternalStore hook. I hope you now can find some good use cases for it, such as writing your own Redux-like library.

#react #hooks