Global States With Subscription

Global States With Subscription

·

6 min read

In a previous article, I talked about how to implement global states in react by context. In this article, let's see how to do it manually, the so-called subscription pattern.

The main problem when we implement global states with context is that we need to prevent over rerender. On the contrary, if we want to implement global states manually, we need to figure out a way to rerender components automatically.

Take below code as an example. We define a global state count and display it in the react component. Whenever the button is clicked, the variable will be added by 1.

let count = 0;

const Component = () => {
  return (
    <div>
      count1: {count} <button onClick={() => count++}>+1</button>
    </div>
  );
};

Of course this will not work. Even if the variable is incremented, but the component is not rerendered. So the displayed value is still the original.

So how could we make the component rerender? Yes, we need to use setState. So we may come up with below solution.

let count = 0;

const Component = () => {
  const [state, setState] = useState(count);

  const onClick = () => {
    count++;
    setState(count);
  }
  return (
    <div>
      count1: {count} <button onClick={onClick}>+1</button>
    </div>
  );
};

We use a local state which has the same value with the global state to tigger the rerender. Yes, this should work. For this component.

But remember that global state is designed for sharing but multiple component. So let see if this solution works in multiple component.


let count = 0;

const Component1 = () => {
  const [state, setState] = useState(count);
  const onClick = () => {
    count++;
    setState(count);
  }
  return (
    <div>
      count1: {count} <button onClick={onClick}>+1</button>
    </div>
  );
};

const Component2 = () => {
  const [state, setState] = useState(count);

  const onClick = () => {
    count++;
    setState(count);
  }

  return (
    <div>
      count2: {count} <button onClick={onClick}>+1</button>
    </div>
  );
};

Seems not working. We expect the click on the component1 changes the global state, which should trigger rerender for both component. But now only the component being clicked is rerendered.

So we need to figure out a way to trigger rerender to all the component using the global state. We only have one tool to trigger rerender, yes the setState function. So we can store all the setState functions related with the global state and call them when the global state changes.


let count = 0;
let setStates = new Set();

const Component1 = () => {
  const [state, setState] = useState(count);

  useEffect(() => {
    setStates.add(setState);
    return () => {
      setStates.delete(setState);
    };
  }, []);

  const onClick = () => {
    count++;
    setStates.forEach(setState => setState(count));
  };

  return (
    <div>
      count1: {count} <button onClick={onClick}>+1</button>
    </div>
  );
};


const Component2 = () => {
  const [state, setState] = useState(count);

  useEffect(() => {
    setStates.add(setState);
    return () => {
      setStates.delete(setState);
    };
  }, []);

  const onClick = () => {
    count++;
    setStates.forEach(setState => setState(count));
  };

  return (
    <div>
      count2: {count} <button onClick={onClick}>+1</button>
    </div>
  );
};

Now we basically solve the problem of rerender. But our logic is scattered all over the place. Let's do some refactoring by writing a function to create the global states and return some utility functions.

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

  const getState = () => state;

  const setState = (nextState) => {
    state = nextState;
    callbacks.forEach((callback) => callback());
  };

  const subscribe = (callback) => {
    callbacks.add(callback);
    return () => {
      callbacks.delete(callback);
    };
  };

  return { getState, setState, subscribe };
};

As you can see, we use the subscribe function to pass in all the setState function and call all of them in our own setState function.

Next, let's create a store and a custom hook to handle the basic logic.

const store = createStore({ count: 0 });

const useStore = (store) => {
  const [state, setState] = useState(store.getState());

  useEffect(
    () =>
      store.subscribe(() => {
        setState(store.getState());
      }),
    [store]
  );

  return [state, store.setState];
};

Now is simple. We could use this useStore hook just like the built-in useState hook.

const Component1 = () => {
  const [state, setState] = useStore(store);

  const onClick = () => {
    setState({ ...state, count: state.count + 1 });
  };

  return (
    <div>
      count1: {state.count} <button onClick={onClick}>+1</button>
    </div>
  );
};

const Component2 = () => {
  const [state, setState] = useStore(store);

  const onClick = () => {
    setState({ ...state, count: state.count + 1 });
  };

  return (
    <div>
      count2: {state.count} <button onClick={onClick}>+1</button>
    </div>
  );
};

This seems better. But we still have something to consider. If you think about it, you should see that now our createStore is bit like the built-in createContext. If the states change, all then subscripted component will be rerendered.

For example, if our store state is not just a count varibale, but have multiple attributes. In that case, we may expect that the component should only rerender when the related states change. So we need to figure out another way to solve this.

Remember how we make the rerender of all component possible? The secret is that we actually create local state by built-in useState hook with the same value of the global state. So we can just select the relevant globals states and only create local states based on them, so the setState function will only trigger rerender when the relevant states change. This is the so-called selector pattern.

For implementation, we don't need to change createStore function at all. We only need to change the useStore hook to accept a selector function.

const useStore = (store, selector) => {
  // select relevant states
  const [state, setState] = useState(() => selector(store.getState()));

  useEffect(
    () =>
      store.subscribe(() => {
        // set relevant states
        setState(selector(store.getState()));
      }),
    [store, selector]
  );

  return [state, store.setState];
};

Now we can use different states without triggering over rerender.

const store = createStore({ count1: 0, count2: 0 });

const selector1 = (state) => state.count1;
const Component1 = () => {
  const [state, setState] = useStore(store, selector1);

  const onClick = () => {
    setState({ ...store.getState(), count1: state + 1 });
  };

  return (
    <div>
      count1: {state} <button onClick={onClick}>+1</button>
    </div>
  );
};

const selector2 = (state) => state.count2;
const Component2 = () => {
  const [state, setState] = useStore(store, selector2);

  const onClick = () => {
    setState({ ...store.getState(), count2: state + 1 });
  };

  return (
    <div>
      count2: {state} <button onClick={onClick}>+1</button>
    </div>
  );
};

function App() {
  return (
    <div>
      <Component1 />
      <Component1 />
      <Component2 />
      <Component2 />
    </div>
  );
}

We are almost done here. Actually react provides an api called useSyncExternalStore for this kind of global states management. Its api is the same with we built above. We could change our function useStore as below, and it should work like a charm.

import { useSyncExternalStore } from "react";

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

That's all. Thanks for reading.