- 8 min read

useSyncExternalStore in React

React
Typescript
Laravel
Markdown logo

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).