Meet the new hook useSyncExternalStore, introduced in React 18 for external stores


Before diving into the useSyncExternalStore API let us get familiar with the terms which would be useful to understand the new hook.

Concurrent rendering and startTransition API

Concurrency is the mechanism to execute multiple tasks simultaneously by prioritizing the tasks. This concept is explained in an easy way by Dan Abramov with an analogy of phone calls.

We can opt-in to keep the app responsive while rendering with the help of the startTransition API. In other words, React can now render with pauses. This allows browsers to handle events in between.

Check out more details on the startTransition API, which we have written in our previous post.

External store

The external store is something which we can subscribe to. Examples of the external store include Redux store, Zustand store, global variables, module scope variables, DOM state, etc.

Internal stores

Internal stores include props, context, useState, useReducer.

Tearing

Tearing refers to visual inconsistency. It means that a UI shows multiple values for the same state.

Before React 18, this issue did not come up. But in React 18, concurrent rendering makes this issue possible because React pauses during rendering. Between these pauses, updates can pull in the changes related to the data being used to render. It causes the UI to show two different values for the same data.

Let us consider the example mentioned in the WG discussion of tearing.

Here, a component needs to access some external store to get the color.

With synchronous rendering, the color rendered on UI is consistent.

In concurrent rendering, initially, the color fetched is blue. React yields, and the store gets updated to red. React continues rendering with the updated value red. It causes inconsistency in UI, which is known as ‘tearing’.

To fix this issue, the React team added useMutableSource hook to safely and efficiently read from a mutable external source. But, members of the working group reported flaws with the existing API contract that make it difficult for library maintainers to adopt useMutableSource in their implementations. After a lot of discussions, the useMutableSource hook was redesigned and renamed to useSyncExternalStore.

Understanding useSyncExternalStore hook

The new useSyncExternalStore hook available in React 18 allows to properly subscribe to values in stores.

To help simplify the migration, React provides a new package use-sync-external-store. This package has a shim that works with any React, which has support for hooks.

import {useSyncExternalStore} from 'react';

  or

// Backwards compatible shim
import {useSyncExternalStore} from 'use-sync-external-store/shim';

//Basic usage. getSnapshot must return a cached/memoized result
useSyncExternalStore(
  subscribe: (callback) => Unsubscribe
  getSnapshot: () => State
) => State

// Selecting a specific field using an inline getSnapshot
const selectedField = useSyncExternalStore(store.subscribe, () => store.getSnapshot().selectedField);

useSyncExternalStore hook takes two functions

  • ‘subscribe’ function to register a callback function
  • ‘getSnapshot’ is used to check if the subscribed value has changed since the last time, it was rendered, It either needs to be an immutable value like a string or number, or it needs to be a cached/memoized object. The immutable value is then returned by the hook.

A version of the API with automatic support for memoizing the result of getSnapshot:

import {useSyncExternalStoreWithSelector} from 'use-sync-external-store/with-selector';

const selection = useSyncExternalStoreWithSelector(
  store.subscribe,
  store.getSnapshot,
  getServerSnapshot,
  selector,
  isEqual
);

Let us check out the example discussed in a React 18 for External Store Libraries talk by Daishi Kato.

import React, { useState, useEffect, useCallback, startTransition } from "react";

// library code

const createStore = (initialState) => {
  let state = initialState;
  const getState = () => state;
  const listeners = new Set();
  const setState = (fn) => {
    state = fn(state);
    listeners.forEach((l) => l());
  }
  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  }
  return {getState, setState, subscribe}
}

const useStore = (store, selector) => {
  const [state, setState] = useState(() => selector(store.getState()));
  useEffect(() => {
    const callback = () => setState(selector(store.getState()));
    const unsubscribe = store.subscribe(callback);
    callback();
    return unsubscribe;
  }, [store, selector]);
  return state;
}

//Application code

const store = createStore({count: 0, text: 'hello'});

const Counter = () => {
  const count = useStore(store, useCallback((state) => state.count, []));
  const inc = () => {
    store.setState((prev) => ({...prev, count: prev.count + 1}))
  }
  return (
    <div>
      {count} <button onClick={inc}>+1</button>
    </div>
  );
}

const TextBox = () => {
  const text = useStore(store, useCallback((state) => state.text, []));
  const setText = (event) => {
    store.setState((prev) => ({...prev, text: event.target.value}))
  }
  return (
    <div>
      <input value={text} onChange={setText} className='full-width'/>
    </div>
  );
}

const App = () => {
  return(
    <div className='container'>
      <Counter />
      <Counter />
      <TextBox />
      <TextBox />
    </div>
  )
}

If we use startTransition somewhere in the code, it may lead to tearing. To fix the tearing issue we can now use the useSyncExternalStore API.

Let us modify the useStore hook of the library to use useSyncExternalStore instead of the useEffect and useState hooks.

import { useSyncExternalStore } from 'react';

const useStore = (store, selector) => {
  return useSyncExternalStore(
    store.subscribe,
    useCallback(() => selector(store.getState(), [store, selector]))
  )
}

The code looks clean, maintainable, and safe with the new hook. Migration to the useSyncExternalStore hook in external stores is easy and recommended to avoid any potential issues.

What kinds of libraries are affected by concurrent rendering?
  • Libraries that have components and custom hooks which don’t access external mutable data while rendering and only pass information using React props, state or context, are not affected.

  • The libraries which deal with data fetching, state management, or styling (Redux, MobX, Relay) are affected. It is because these libraries store their state outside of React. With concurrent rendering, those data stores can be updated in the middle of rendering, without React knowing about it.

To know more about the useSyncExternalStore hook, read through the list of links we have compiled -

Join Our Newsletter