React Context without Provider + useMutableSource

aslemammad

Mohammad Bagher Abiyat

Posted on February 24, 2021

React Context without Provider + useMutableSource

This post was originally posted on my personal blog.

A while ago, I was reading an RFC from react's RFCs called useMutableSource; it was an experimental feature that, in a nutshell, lets you safely read, write and even edit an external source (outside of the react components tree). It's a banger feature, which I'm really chuffed for it, but it's experimental at the same time. You may know that I'm working on an Open-source state-management library called jotai. This library announced a new feature, the Provider-less mode. To know what it is, think of React Context, but no need for a Provider component, it's not exactly that, but it gives you the idea.

Why a new one?

Yea, we have patterns and libraries that allow us to read and write from an external source, but as I said, this one lets you do things safely; no tearing anymore.

Tearing

Think of tearing as something like if we have a value(state) that A and B read from it, but somehow in the rendering, the value changes. The B component is later than A, So in Rendering, the value in the A component is 0, and in the newer component (B), the value is 1. We call this tearing; it means you see two different values in the viewport from one exact source. It's a new and hard to understand implementation in React concurrent mode; for more information, see this.

Experimental, Why should I use it?

So I thought about this, we have two options:

  • Experimental version of react: yarn add react@experimental
  • Consistent version of useMutableSource, you can copy paste it from here

I recommend the second option because it's not going to change, and good for now as long as we don't have useMutableSource in a major react version.

Context with no Provider

I think we have reached what brought you here, but wait before all of this, don't forget to look at my Github and Twitter; you're going to see cool stuff there and help me with my learning journey too. So let's start.

Some of the code below is written with typescript, so it will be more understandable, even for people who don't know typescript.

Start

First we need to create a simple global object, which contains three properties:

 const globalStore = {
  state: { count: 0 },
  version: 0,
  listeners: new Set<() => any>()
};
Enter fullscreen mode Exit fullscreen mode
  • state: simple value like react Context value
  • version: important part that has to change whenever any part of the state changes
  • listeners: a set of functions that we call them every time we change part of the state, so we notify them about the changes

Now we need to create a mutable source from globalStore and give it the version, so it'll help it with triggering new changes, so we're going to access it in getSnapshot and subscribe; we'll talk about these soon.

const globalStoreSource = createMutableSource(
  globalStore,
  () => globalStore.version // (store) => store.version (Optional) if you use the consistent and non-experimental version of useMutableSource
);
Enter fullscreen mode Exit fullscreen mode

Now it's the time to talk about getSnapshot; in a nutshell, it's a function that useMutableSource returns its returned value whenever the state changes.

const cache = new Map();

const getSnapshot = (store: typeof globalStore) => {
  const setState = (
    cb: (prevState: typeof store.state) => typeof store.state
  ) => {
    store.state = cb({ ...store.state });
    store.version++;
    store.listeners.forEach((listener) => listener());
  };
  if (!cache.has(store.state) || !cache.has(store)) {
    cache.clear(); // remove all the old references
    cache.set(store.state, [{ ...store.state }, setState]); 
    // we cache the result to prevent the useless re-renders
    // the key (store.state) is more consistent than the { ...store.state },
    // because this changes everytime as a new object, and it always going to create a new cache
    cache.set(store, store); // check the above if statement, if the store changed completely (reference change), we'll make a new result and new state
  }

  return cache.get(store.state); // [state, setState]
};
// later: const [state, setState] = useMutableSource(...)
Enter fullscreen mode Exit fullscreen mode

Take a look at the setState function, first we use cb and pass it the previous state, then assign its returned value to our state, then we update the store version and notify all the listeners of the new change.

we used the spread operator ({ ...store.state }) because we have to clone the value, so we make a new reference for the new state object and disable direct mutations.

We don't have any listener yet, so how we can add one? with the subscribe function, take a look at this:

const subscribe = (store: typeof globalStore, callback: () => any) => {
  store.listeners.add(callback);
  return () => store.listeners.delete(callback);
};
Enter fullscreen mode Exit fullscreen mode

This function's going to get called by useMutableSource, So it passes subscribe two parameters:

  • store: which is our original store
  • callback: this is going to cause our component a re-render (by useMutableSource)

So when useMutableSource calls the subscribe, we're going to add the callback to our listeners. Whenever something changes in the state (setState), we call all of our listeners so that the component will get re-rendered. That's how we have the updated value every time with useMutableSource.

So you may wonder we delete the callback in return, the answer is that when the component unmounts, useMutableSource will call subscribe(), or in another term, we call it unsubscribe. When it gets deleted, we'll no longer call a useless callback that will cause a re-render to an unmounted (or sometimes an old) component.

useContext

Now we reached the end line, don't think too much about the name, we just wanted to mimic the Provider-less version of React context.

export function useContext() {
  return useMutableSource(globalStoreSource, getSnapshot, subscribe);
} // returns [state, setState]
Enter fullscreen mode Exit fullscreen mode

Now we can use this function everywhere we want. Take a look at this example, or if you want, you could go straight for the codesandbox.

function Display1() {
  const [state] = useContext();
  return <div>Display1 component count: {state.count}</div>;
}
function Display2() {
  const [state] = useContext();
  return <div>Display2 component count: {state.count}</div>;
}
function Changer() {
  const [, setState] = useContext();
  return (
    <button
      onClick={() =>
        setState((prevState) => ({ ...prevState, count: ++prevState.count }))
      }
    >
      +1
    </button>
  );
}
function App() {
  return (
    <div className="App">
      <Display1 />
      <Display2 />
      <Changer />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now whenever you click the +1 button, you can see the beautiful changes without any Provider.

I hope you enjoyed this article, and don't forget to share and reaction to my article. If you wanted to tell me something, tell me on Twitter or mention me anywhere else, You can even subscribe to my newsletter.

  • Cover image: Experiment, Nicolas Thomas, unsplash
💖 💪 🙅 🚩
aslemammad
Mohammad Bagher Abiyat

Posted on February 24, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related