A React component usually uses data coming from a state (useState, useReducer…), input properties, or a context (useContext). All of this is managed by React.
But how do we operate when we want to follow data that React doesn’t manage? For example, how do libraries like MobX or Zustand provide a state that seems naturally followed by React?
Follow an external data
useSyncExternalStore, available since React 18, allows to read and follow data coming from outside of React, for example : a state management library, or a value provided by the browser, that could change.
In the browser
When creating a Laravel application with Inertia and the React Starter Kit, we end up with a lot of code already generated on the front-end side.
Among generated files, we can find hooks in the resources/js/hooks directory. Two of them, useIsMobile and useAppearance, allow to follow the dimensions of the browser and the theme to apply to the page (light or dark).
useIsMobile
Let’s take a look at the code of useIsMobile hook.
1 - mql : MediaQueryList
The starting point of this feature is in mql, a MediaQueryList that contains the information we need : is the window width less than MOBILE_BREAKPOINT - 1 (767px) ?
const mql =
typeof window === "undefined"
? undefined
: window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
So the question we want to ask is: does the media query declared in mql match or not ?
To create this media query, we used window.matchMedia, a feature available in the browser API. So it’s not a data we declared in our application, as a state.
2 - Value changes
To follow the evolution of this data, we will listen to the change event on the media query :
mql.addEventListener("change", callback);
The information about this media query lives outside of our application, in the browser, and its evolution is managed by an event, also external to our application.
3 - Synchronization with React
We want to synchronize with this data, keep an eye on it (observe or listen) so that React knows when to trigger a re-render of components that need it.
This is where useSyncExternalStore is useful :
export function useIsMobile(): boolean {
return useSyncExternalStore(
mediaQueryListener, // subscribe
isSmallerThanBreakpoint, // getSnapshot
getServerSnapshot, // getServerSnapshot
);
}
For the subscribe parameter, we pass the mediaQueryListener function that takes a callback parameter. React will call this function and provide the callback.
function mediaQueryListener(callback: (event: MediaQueryListEvent) => void) {
//...
mql.addEventListener("change", callback);
//...
}
4 - Reading the value
To retrieve the value, React will use the second parameter passed to useSyncExternalStore: getSnapshot. We pass it the isSmallerThanBreakpoint function.
export function useIsMobile(): boolean {
return useSyncExternalStore(
mediaQueryListener, // subscribe
isSmallerThanBreakpoint, // getSnapshot
getServerSnapshot,
);
}
In our case, we will return the information we want : does the media query match or not ?
function isSmallerThanBreakpoint(): boolean {
return mql?.matches ?? false;
}
When this value changes, React knows that it needs to re-render the components that use this hook.
For instance, in our Laravel application with Inertia, in the resources/js/components/nav-user.tsx component :
export function NavUser() {
//...
const isMobile = useIsMobile(); // Getting the media query value
return (
{ /* ... */ }
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
align="end"
side={
isMobile
? 'bottom' // If mobile, display at the bottom
: state === 'collapsed'
? 'left'
: 'bottom'
}
>
{/* ... */}
</DropdownMenuContent>
{ /* ... */ }
);
}
5 - Server-side rendering
When rendering a component server-side, we don’t have any window object, so we can’t access any browser API. mql will therefore be undefined and getServerSnapshot will return false.
const mql =
typeof window === "undefined"
? undefined
: window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
//...
function getServerSnapshot(): boolean {
return false;
}
6 - Cleanup
The mediaQueryListener subscription method returns a function. Like what the useEffect hook returns, its role is to clean up behind it, in two situations :
- Component unmounting
- Change of subscription function reference
The cleanup process is simple, we remove the listener we installed on subscription :
function mediaQueryListener(callback: (event: MediaQueryListEvent) => void) {
//...
// Cleanup function
return () => {
mql.removeEventListener("change", callback);
};
}
Our subscription function is mediaQueryListener, defined in the useIsMobile.tsx file, so at module level: it is stable and its reference will not change. The synchronization is therefore removed (cleaned up) only when the component is unmounted.
useAppearance
Now, let’s take a look at the code of useAppearance hook.
1 - currentAppearance
currentAppearance is the data containing the appearance (light, dark or system) and that will change. It will serve as a snapshot when React will need to retrieve the appearance. So it’s the value of currentAppearance we want to follow in React.
let currentAppearance: Appearance = "system";
2 - Changes
currentAppearance can be modified in two cases :
- initialization of the theme from the
localStorage(not related to our hook, is called on application load) - user interaction
3 - Connection with React
In the useAppearance hook, we synchronize our currentAppearance with React :
export function useAppearance(): UseAppearanceReturn {
const appearance: Appearance = useSyncExternalStore(
subscribe,
() => currentAppearance,
() => "system",
);
//...
}
The subscribe method, manually adds a listener that will be called when the appearance changes.
4 - Updates
The useAppearance hook defines and returns an updateAppearance function :
export function useAppearance(): UseAppearanceReturn {
//...
const updateAppearance = useCallback((mode: Appearance): void => {
currentAppearance = mode;
// Store in localStorage for client-side persistence...
localStorage.setItem("appearance", mode);
// Store in cookie for SSR...
setCookie("appearance", mode);
applyTheme(mode);
notify();
}, []);
return { appearance, resolvedAppearance, updateAppearance } as const;
}
In this method, we apply the chosen theme, and then notify the listeners: at this moment, React can trigger the re-render of components that use this hook.
That way, our application is naturally synchronized with all changes made by the user.
External libraries
With two examples, we saw that we can connect a React application to an external data coming from the browser.
But what about in the case of a library?
MobX
MobX is a state management library that can be used in a React application.
In its React integration, MobX includes an Observer component and a higher-order component (HOC), observer, allowing to automatically trigger a re-render when the value of an Observable changes.
To connect the state management (internal to MobX) and the re-render of components (that React is responsible for), there is a useObserver hook which will actually use the useSyncExternalStore hook.
Thus, when an internal state change occurs in MobX, React will be synchronized and will be able to perform a re-render if necessary.
Zustand
Zustand is another state management library, describing itself as an alternative to Redux or the React Context API.
In src/react.ts, we can find a call to useSyncExternalStore to connect the state management logic (specific to Zustand) to the re-render logic (if necessary).
Conclusion
useSyncExternalStore does not replace states managed by React, but allows to follow data external to React, like a state managed by a library or a value provided by the browser (coming from an API or our application).