---
title: useSyncExternalStore in React
date: 2026-02-18
draft: false
description: Discovery and examples of useSyncExternalStore hook's usage
categories:
  - react
  - typescript
  - laravel
---

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](https://mobx.js.org/README.html) or [Zustand](https://zustand-demo.pmnd.rs/) provide a state that seems naturally followed by React?

## Follow an external data

[`useSyncExternalStore`](https://react.dev/reference/react/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.

:::important

[`useSyncExternalStore`](https://react.dev/reference/react/useSyncExternalStore) is used to follow data that is not managed by React. The application will "subscribe" to this data (synchronize) and follow the evolution of its value.

:::

## In the browser

When creating a [Laravel](https://laravel.com/) application with [Inertia](https://inertiajs.com/) 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.

:::important

The code might be a bit long, you can find it [at this address](https://github.com/ld-web/useSyncExternalStore-examples/blob/main/use-mobile.tsx) and follow the sections of this article with the corresponding numbers in the code.

:::

#### 1 - `mql` : MediaQueryList

The starting point of this feature is in `mql`, a [`MediaQueryList`](https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList) that contains the information we need : is the window width less than `MOBILE_BREAKPOINT - 1` (767px) ?

```ts
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`](https://developer.mozilla.org/en-US/docs/Web/API/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`](https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList/change_event) event on the media query :

```ts
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`](https://react.dev/reference/react/useSyncExternalStore) is useful :

```ts
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.

```ts
function mediaQueryListener(callback: (event: MediaQueryListEvent) => void) {
  //...
  mql.addEventListener("change", callback);
  //...
}
```

:::important

This function allows us to "connect" the source (media query) with React.

`callback` is a function provided by React, and we add it as a `listener` to the `change` event.

When the `change` event occurs (therefore the width becomes less or greater than 767px), `callback` is called. Since it is managed by React, React then knows that it needs to trigger a re-render (if the value has changed).

It's like if we had done a `setState` (that actually asks for a re-render to React), but with an external data source.

:::

#### 4 - Reading the value

To retrieve the value, React will use the second parameter passed to `useSyncExternalStore`: `getSnapshot`. We pass it the `isSmallerThanBreakpoint` function.

```ts
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 ?

```ts
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 :

```tsx
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`.

```ts
const mql =
  typeof window === "undefined"
    ? undefined
    : window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);

//...

function getServerSnapshot(): boolean {
  return false;
}
```

:::note

`getServerSnapshot` will also be called client-side during the hydration phase. The returned value must be the same as the one returned server-side (here `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 :

```ts
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.

:::important

The code might be a bit long, you can find it [at this address](https://github.com/ld-web/useSyncExternalStore-examples/blob/main/use-appearance.tsx) and follow the sections of this article with the corresponding numbers in the code.

:::

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

```ts
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 :

```ts
export function useAppearance(): UseAppearanceReturn {
  const appearance: Appearance = useSyncExternalStore(
    subscribe,
    () => currentAppearance,
    () => "system",
  );

  //...
}
```

:::note

`useSyncExternalStore`, after its first execution, returns a first snapshot of the appearance, so the default value: `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 :

```ts
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.

:::note

When the appearance is set to `system`, the theme changes are dynamic, and processed through a `change` listener on the following media query :

```ts
const mediaQuery = (): MediaQueryList | null => {
  if (typeof window === "undefined") return null;

  return window.matchMedia("(prefers-color-scheme: dark)");
};
```

On application load, a listener is installed on this media query, to handle system theme change :

```ts
export function initializeTheme(): void {
  //...
  mediaQuery()?.addEventListener("change", handleSystemThemeChange);
```

In this case, it is not React that triggers the re-render of components. `handleSystemThemeChange` calls `applyTheme` which will modify the `html` tag :

```ts
const applyTheme = (appearance: Appearance): void => {
  if (typeof document === "undefined") return;

  const isDark = isDarkMode(appearance);

  document.documentElement.classList.toggle("dark", isDark);
  document.documentElement.style.colorScheme = isDark ? "dark" : "light";
};
```

:::

## 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](https://mobx.js.org/README.html) 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](https://github.com/mobxjs/mobx/blob/main/packages/mobx-react-lite/src/useObserver.ts#L94-L99) 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](https://zustand-demo.pmnd.rs/) is another state management library, describing itself as an alternative to [Redux](https://react-redux.js.org/) or the React [Context API](https://react.dev/reference/react/createContext).

In [`src/react.ts`](https://github.com/pmndrs/zustand/blob/main/src/react.ts#L30-L34), 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`](https://react.dev/reference/react/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).
