Replacing Context API and State Management Libraries with useSyncExternalStore

In React apps, managing state across several components is essential to a seamless user experience. While the Context API is a common tool for managing global states, it often leads to unnecessary re-renders. When a context value changes, all components using it useContext to consume the context will re-render, even if the specific value they depend on hasn’t changed.

Fortunately, React introduced the useSyncExternalStore hook, which provides a more efficient way to subscribe to changes from an external store. By using useSyncExternalStore with a global store, we can ensure that only the relevant components re-render when the corresponding state updates.

In this blog, we will walk through the differences between using the Context API and useSyncExternalStore for state management and demonstrate how the latter helps prevent unnecessary re-renders. Let’s dive into the code!

The Problem with Context API

In a simple application, let’s assume we have two components: one that displays the temperature and another that allows users to pick a color. Both values are managed using the Context API.

import { createContext, useContext, useState } from "react";

// Create context
const AppContext = createContext();

function App() {
  const [temperature, setTemperature] = useState(22);
  const [color, setColor] = useState("#ff0000");

  return (
    <AppContext.Provider
      value={{
        temperature,
        color,
        setTemperature,
        setColor,
      }}
    >
      <TemperatureDisplay />
      <ColorPicker />
    </AppContext.Provider>
  );
}

function TemperatureDisplay() {
  const { temperature, setTemperature } = useContext(AppContext);

  const increaseTemp = () => {
    setTemperature(temperature + 1);
  };

  return (
    <div>
      <h3>Temperature: {temperature}°C</h3>
      <button onClick={increaseTemp}>Increase Temperature</button>
    </div>
  );
}

function ColorPicker() {
  const { color, setColor } = useContext(AppContext);

  const setColorValue = (e) => {
    setColor(e.target.value);
  };

  return (
    <div>
      <h3>Selected Color: {color}</h3>
      <input type="color" value={color} onChange={setColorValue} />
    </div>
  );
}

Here, TemperatureDisplay and ColorPicker both consume the same context. If either the temperature or color changes, both components will re-render because they are accessing the same context, even though the other component doesn't rely on the changed value.

Introducing useSyncExternalStore

To avoid unnecessary re-renders, we can replace the Context API with a global store approach using useSyncExternalStore. This hook was introduced to allow React components to subscribe to external stores, and it offers fine-grained control over which components re-render when the store’s state changes.

By using useSyncExternalStore, we ensure that components only re-render when the specific part of the state they are subscribed to changes.

Let’s implement this more efficient pattern!

Setting Up a Global Store

We will create a simple global store using a custom createStore function that manages state and allows components to subscribe to specific parts of the store.

store.js

const createStore = (initialState) => {
  let state = initialState;
  const listeners = new Set();

  const setState = (fn) => {
    state = fn(state);
    listeners.forEach((listener) => listener());
  };

  const getSnapshot = () => state;

  const subscribe = (listener) => {
    listeners.add(listener);
    return () => {
      listeners.delete(listener);
    };
  };

  const getServerSnapshot = () => state;

  return {
    setState,
    getSnapshot,
    subscribe,
    getServerSnapshot,
  };
};

export default createStore;

The createStore function allows us to initialize a store with initialState and manage the updates. It also lets components subscribe to state changes via a listener.

globalStore.js

We create an instance of the store with the initial values for temperature and color.

import createStore from "./store";

const store = createStore({
  temperature: 22,
  color: "#ff0000",
});

export default store;

Using useSyncExternalStore to Subscribe to the Store

Next, we use useSyncExternalStore to allow components to subscribe to the part of the global store they care about. This hook solves our re-render problem by ensuring that only the component that depends on the updated part of the state re-renders.

useStore.js

import { useSyncExternalStore } from "react";

const useStore = (store, selector) =>
  useSyncExternalStore(
    store.subscribe,
    () => selector(store.getSnapshot()),
    () => selector(store.getServerSnapshot())
  );

export default useStore;

In useStore, the useSyncExternalStore hook subscribes to the store and uses a selector function to allow components to access the specific part of the store they need.

Updating Components

Now, we will update the components to use useStore instead of useContext to access the global store.

TemperatureDisplay.jsx

import useStore from "./useStore";
import store from "./globalStore";

const TemperatureDisplay = () => {
  const temperature = useStore(store, (state) => state.temperature);

  const increaseTemp = () => {
    store.setState((prev) => ({
      ...prev,
      temperature: prev.temperature + 1,
    }));
  };

  return (
    <div>
      <h3>Temperature: {temperature}°C</h3>
      <button onClick={increaseTemp}>Increase Temperature</button>
    </div>
  );
};

export default TemperatureDisplay;

ColorPicker.jsx

import useStore from "./useStore";
import store from "./globalStore";

const ColorPicker = () => {
  const color = useStore(store, (state) => state.color);

  const setColor = (e) => {
    store.setState((prev) => ({
      ...prev,
      color: e.target.value,
    }));
  };

  return (
    <div>
      <h3>Selected Color: {color}</h3>
      <input type="color" value={color} onChange={setColor} />
    </div>
  );
};

export default ColorPicker;

The Key Difference

Using useSyncExternalStore ensures that only the relevant component re-renders when its associated value changes.

  • When the temperature is increased, only the TemperatureDisplay component re-renders, while the ColorPicker remains unchanged.

  • Similarly, when the color is updated, only the ColorPicker component re-renders.

This is a huge improvement over the Context API, where both components would re-render regardless of whether the updated value was relevant to them or not.

// With Context API (both components re-render):
<TemperatureDisplay />  // ✅
<ColorPicker />  // ⚠️ Unnecessary re-render

// With useSyncExternalStore (only relevant component re-renders):
<TemperatureDisplay />  // ✅
<ColorPicker />  // ❌ No re-render

Conclusion

By using useSyncExternalStore, we can prevent unnecessary re-renders, improving performance in React applications. This pattern is especially beneficial in applications with complex state management needs, as it allows components to subscribe only to the parts of the global state they depend on. It’s a powerful alternative to the Context API and third-party state management libraries, ensuring your React apps remain efficient and responsive.