DEV Community

Cover image for Steps to Develop Global State for React With Hooks Without Context
Daishi Kato
Daishi Kato

Posted on • Originally published at blog.axlight.com

Steps to Develop Global State for React With Hooks Without Context

Support Concurrent Mode

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.

https://github.com/dai-shi/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',
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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());
};
Enter fullscreen mode Exit fullscreen mode

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

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

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,
  };
};
Enter fullscreen mode Exit fullscreen mode

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,
  };
};
Enter fullscreen mode Exit fullscreen mode

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());
  };

  // ...
Enter fullscreen mode Exit fullscreen mode

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,
  };
};
Enter fullscreen mode Exit fullscreen mode

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]);
  };
Enter fullscreen mode Exit fullscreen mode

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.


Originally published at https://blog.axlight.com on February 18, 2020.

Top comments (0)