Back

Steps to Develop Global State for React With Hooks Without Context

Steps to Develop Global State for React With Hooks Without Context

Introduction

Developing with React hooks is fun for me. I have been developing several libraries. The very first library was a library for global state. It’s naively called “react-hooks-global-state” which turns out to be too long to read.

The initial version of the library was published in Oct 2018. Time has passed since then, I learned a lot, and now v1.0.0 of the library is published (react-hooks-global-state).

This post shows simplified versions of the code step by step. It would help understand what this library is aiming at, while the real code is a bit complex in TypeScript.

Step 1: Global variable

let globalState = {
  count: 0,
  text: 'hello',
};

Let’s have a global variable like the above. We assume this structure throughout this post. One would create a React hook to read this global variable.

const useGlobalState = () => {
  return globalState;
};

This is not actually a React hook because it doesn’t depend on any React primitive hooks.

Now, this is not what we usually want, because it doesn’t re-render when the global variable changes.

Step 2: Re-render on updates

We need to use React useState hook to make it reactive.

const listeners = new Set();

const useGlobalState = () => {
  const [state, setState] = useState(globalState);
  useEffect(() => {
    const listener = () => {
      setState(globalState);
    };
    listeners.add(listener);
    listener(); // in case it's already changed
    return () => listeners.delete(listener); // cleanup
  }, []);
  return state;
};

This allows to update React state from outside. If you update the global variable, you need to notify listeners. Let’s create a function for updating.

const setGlobalState = (nextGlobalState) => {
  globalState = nextGlobalState;
  listeners.forEach(listener => listener());
};

With this, we can change useGlobalState to return a tuple like useState.

const useGlobalState = () => {
  const [state, setState] = useState(globalState);
  useEffect(() => {
    // ...
  }, []);
  return [state, setGlobalState];
};

Step 3: Container

Usually, the global variable is in a file scope. Let’s put it in a function scope to narrow down the scope a bit and make it more reusable.

const createContainer = (initialState) => {
  let globalState = initialState;
  const listeners = new Set();

  const setGlobalState = (nextGlobalState) => {
    globalState = nextGlobalState;
    listeners.forEach(listener => listener());
  };

  const useGlobalState = () => {
    const [state, setState] = useState(globalState);
    useEffect(() => {
      const listener = () => {
        setState(globalState);
      };
      listeners.add(listener);
      listener(); // in case it's already changed
      return () => listeners.delete(listener); // cleanup
    }, []);
    return [state, setGlobalState];
  };

  return {
    setGlobalState,
    useGlobalState,
  };
};

We don’t go in detail about TypeScript in this post, but this form allows to annotate types of useGlobalState by inferring types of initialState.

Step 4: Scoped access

Although we can create multiple containers, usually we put several items in a global state.

Typical global state libraries have some functionality to scope only a part of the state. For example, React Redux uses selector interface to get a derived value from a global state.

We take a simpler approach here, which is to use a string key of a global state. In our example, it’s like count and text.

const createContainer = (initialState) => {
  let globalState = initialState;
  const listeners = Object.fromEntries(Object.keys(initialState).map(key => [key, new Set()]));

  const setGlobalState = (key, nextValue) => {
    globalState = { ...globalState, [key]: nextValue };
    listeners[key].forEach(listener => listener());
  };

  const useGlobalState = (key) => {
    const [state, setState] = useState(globalState[key]);
    useEffect(() => {
      const listener = () => {
        setState(globalState[key]);
      };
      listeners[key].add(listener);
      listener(); // in case it's already changed
      return () => listeners[key].delete(listener); // cleanup
    }, []);
    return [state, (nextValue) => setGlobalState(key, nextValue)];
  };

  return {
    setGlobalState,
    useGlobalState,
  };
};

We omit the use of useCallback in this code for simplicity, but it’s generally recommended for a library.

Step 5: Functional Updates

React useState allows functional updates. Let’s implement this feature.

// ...

  const setGlobalState = (key, nextValue) => {
    if (typeof nextValue === 'function') {
      globalState = { ...globalState, [key]: nextValue(globalState[key]) };
    } else {
      globalState = { ...globalState, [key]: nextValue };
    }
    listeners[key].forEach(listener => listener());
  };

  // ...

Step 6: Reducer

Those who are familiar with Redux may prefer reducer interface. React hook useReducer also has basically the same interface.

const createContainer = (reducer, initialState) => {
  let globalState = initialState;
  const listeners = Object.fromEntries(Object.keys(initialState).map(key => [key, new Set()]));

  const dispatch = (action) => {
    const prevState = globalState;
    globalState = reducer(globalState, action);
    Object.keys((key) => {
      if (prevState[key] !== globalState[key]) {
        listeners[key].forEach(listener => listener());
      }
    });
  };

  // ...

  return {
    useGlobalState,
    dispatch,
  };
};

Step 6: Concurrent Mode

In order to get benefits from Concurrent Mode, we need to use React state instead of an external variable. The current solution to it is to link a React state to our global state.

The implementation is very tricky, but in essence we create a hook to create a state and link it.

const useGlobalStateProvider = () => {
    const [state, dispatch] = useReducer(patchedReducer, globalState);
    useEffect(() => {
      linkedDispatch = dispatch;
      // ...
    }, []);
    const prevState = useRef(state);
    Object.keys((key) => {
      if (prevState.current[key] !== state[key]) {
        // we need to pass the next value to listener
        listeners[key].forEach(listener => listener(state[key]));
      }
    });
    prevState.current = state;
    useEffect(() => {
      globalState = state;
    }, [state]);
  };

The patchedReducer is required to allow setGlobalState to update global state. The useGlobalStateProvider hook should be used in a stable component such as an app root component.

Note that this is not a well-known technique, and there might be some limitations. For instance, invoking listeners in render is not actually recommended.

To support Concurrent Mode in a proper way, we would need core support. Currently, useMutableSource hook is proposed in this RFC.

Closing notes

This is mostly how react-hooks-global-state is implemented. The real code in the library is a bit more complex in TypeScript, contains getGlobalState for reading global state from outside, and has limited support for Redux middleware and DevTools.

Finally, I have developed some other libraries around global state and React context, as listed below.

Read the original article or more interesting posts on Daishi’s blog.

Open Source Session Replay

OpenReplay is an open-source, session replay suite that lets you see what users do on your web app, helping you troubleshoot issues faster. OpenReplay is self-hosted for full control over your data.

replayer.png

Start enjoying your debugging experience - start using OpenReplay for free.