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 leftonline
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 thegetSnapshot
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.