DEV Community

Cover image for Are React Contexts dead?
Amin
Amin

Posted on

Are React Contexts dead?

The "useSyncExternalStore" is a novel hook that operates by subscribing to changes in a store and transforming the store's snapshot into a usable value for your components. A "snapshot" is simply a term used to depict the state of a store at a specific moment in time.

How useSyncExternalStore work

This new hook operates by subscribing to changes made in a store and converting the store's snapshot into a valuable data for your components.

A "snapshot" is simply a technical term used to describe the state of a store at a particular point in time.

Upon reflection, you might find yourself thinking, "Hey, I can draw a parallel between this explanation and something I already know!"

Window size

Indeed, you're right on target. The window's size can be observed by subscribing to it through the addEventListener method on the window object, and we can capture a snapshot of its value at any moment using the innerWidth property of the window.

import React, { useSyncExternalStore } from "react";

type Subscriber = () => void;

const subscribers: Array<Subscriber> = [];

window.addEventListener("resize", () => {
  subscribers.forEach(notify => {
    notify();
  });
});

const subscribe = () => {
  const subscriber = () => {
    // We don't do nothing in here
  };

  subscribers.push(subscriber);

  return () => {
    const subscriberIndex = subscribers.findIndex(currentSubscriber => {
      return subscriber === currentSubscriber;
    });

    if (subscriberIndex) {
      subscribers.splice(subscriberIndex, 1);
    }
  }:
}

const getSnapshot = () => {
  return window.innerWidth;
}

export const Component = () => {
  const windowWidth = useSyncExternalStore(subscribe, getSnapshot);

  return (
    <span>Width is {windowWidth}</span>
  );
}
Enter fullscreen mode Exit fullscreen mode

With this code, every time we resize the window, the resize event is triggered. This event notifies subscribers, such as the useSyncExternalStore which subscribes to the notification when the component is mounted, and instructs them to obtain a fresh snapshot.

Additionally, it will automatically unsubscribe from the store when the component is unmounted, streamlining this process and relieving us from the need to manage it manually.

Once the snapshot is acquired, useSyncExternalStore updates the value of the windowWidth variable returned by the hook call, which in turn triggers a re-render. Consequently, the updated value will automatically reflect in the JSX because the components are re-invoked.

It's noteworthy that the store is external in this setup, meaning we are not listening for the resize event within the component but rather outside of it.

This also implies that we could induce changes in our API from outside of our React application, for example, when integrating with another framework like Vue.js, by simply triggering a new resize event on the window.

window.dispatchEvent(new CustomEvent("resize"));
Enter fullscreen mode Exit fullscreen mode

And it would update the store without having to use anything from your React application.

What does that means for React Contexts?

If you got the idea right, we could totally create our own store with a limited amount of lines of code.

import React, { useSyncExternalStore } from "react";

type Subscriber = () => void;

class Store<Value> {
  private value: Value;
  private subscribers: Array<Subscriber>;

  public constructor(value: Value) {
    this.value = value;
    this.subscribers = [];
  }

  public emit(newValue: Value): void {
    this.value = newValue;

    this.subscribers.forEach(notify => {
      notify();
    });
  }

  public subscribe(newSubscriber: Subscriber) {
    this.subscribers.push(newSubscriber);

    return () => {
      const subscriberIndex = this.subscribers.findIndex(subscriber => {
        return subscriber === newSubscriber;
      });

      if (subscriberIndex) {
        this.subscribers.splice(subscriberIndex, 1);
      }
    };
  }

  public getSnapshot(): Value {
    return this.value;
  }
}

const useStore = <Value,>(store: Store<Value>) => {
  return useSyncExternalStore(store.subscribe, store.getSnapshot);
};

const counterStore = new Store(0);

export const Component1 = () => {
  const counter = useStore(counterStore);

  return (
    <button onClick={() => counterStore.emit(counter + 1)}>
      {counter}
    </button>
  );
}

export const Component2 = () => {
  const counter = useStore(counterStore);

  return (
    <button onClick={() => counterStore.emit(counter + 1)}>
      {counter}
    </button>
  );
}

export const Component3 = () => {
  const counter = useStore(counterStore);

  return (
    <button onClick={() => counterStore.emit(counter + 1)}>
      {counter}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Absolutely, we've efficiently established a store that shares values using the useSyncExternalStore with just a few lines of code. Once we've defined the class for data storage (you could also have created something other than a class as well), it provides a clean and uncluttered structure.

import React, { useSyncExternalStore } from "react";
import { Store, useStore } from "./store"

const counterStore = new Store(0);

export const Component1 = () => {
  const counter = useStore(counterStore);

  return (
    <button onClick={() => counterStore.emit(counter + 1)}>
      {counter}
    </button>
  );
}

export const Component2 = () => {
  const counter = useStore(counterStore);

  return (
    <button onClick={() => counterStore.emit(counter + 1)}>
      {counter}
    </button>
  );
}

export const Component3 = () => {
  const counter = useStore(counterStore);

  return (
    <button onClick={() => counterStore.emit(counter + 1)}>
      {counter}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Let's compare it to React's Context API using the same logic applied with contexts.

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

type CounterContextProviderProps {
  children: ReactNode
}

const CounterContext = createContext({
  counter: 0,
  setCounter: () => {}
});

const CounterContextProvider = ({ children }: CounterContextProviderProps) => {
  const [counter, setCounter] = useState(0);

  return (
    <CounterContext.Provider value={{ counter, setCounter }}>
      {children}
    </CounterContext.Provider>
  );
}

const useCounter = () => {
  return useContext(CounterContext).value;
}

export const Component1 = () => {
  const { counter, setCounter } = useCounter();

  return (
    <button onClick={() => setCounter(counter + 1)}>
      {counter}
    </button>
  );
}

export const Component2 = () => {
  const { counter, setCounter } = useCounter();

  return (
    <button onClick={() => setCounter(counter + 1)}>
      {counter}
    </button>
  );
}

export const Component3 = () => {
  const { counter, setCounter } = useCounter();

  return (
    <button onClick={() => setCounter(counter + 1)}>
      {counter}
    </button>
  );
}

export const App = () => {
  return (
    <CounterContextProvider>
      <Component1 />
      <Component2 />
      <Component3 />
    </CounterContextProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

While React's Context API is powerful and flexible, it often requires more setup and manual handling of subscriptions and re-renders. useSyncExternalStore simplifies the process, making it more concise and easier to manage for certain use cases.

While React's Contexts are not on the verge of becoming obsolete, it's reasonable to anticipate a decrease in their adoption in favor of this innovative hook.

Redux, Jotai and friends

The useSyncExternalStore is indeed a valuable addition to the React ecosystem, offering a convenient way to set up a store quickly without the need for a provider component. While existing solutions like Jotai and Redux are powerful and provide extensive state management capabilities, they often require the use of a provider component, which can add some complexity to the setup.

The flexibility of useSyncExternalStore allows you to implement your own store logic or adapt it to work with other libraries like rxjs and their Observable or BehaviorSubject classes. This versatility enables you to tailor your state management to your specific needs and preferences, which can be advantageous in certain scenarios.

In summary, the choice between useSyncExternalStore and other state management solutions depends on the requirements and complexity of your project. It's a valuable tool to have in your toolkit, particularly when you need a simple and efficient way to manage shared state in your React application.

@aminnairi/react-signal

I recently published @aminnairi/react-signal on NPM. This library simplifies the creation of signals, akin to the stores we discussed earlier, within your application. Signals enable seamless state sharing among your components.

The operational principle of this library closely aligns with what we previously described. It empowers you with a lightweight solution, ensuring that you confidently work with a tool you fully comprehend, all while avoiding the complexities associated with other existing libraries.

Alternatively, you have the freedom to craft your own custom library. Setting up a store using useSyncExternalStore has become effortlessly straightforward, courtesy of this fantastic new hook.

What's your take on this? Do you believe that React's Contexts have become obsolete? How do you view the existing landscape of state management libraries? Share your thoughts in the comments section!

Top comments (0)