Navigate back to the homepage
BLOG
Try NowLogin
Back

Steps to Develop Global State for React With Hooks Without Context

Daishi Kato
August 31st, 2020 · 2 min read

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

1let globalState = {
2 count: 0,
3 text: 'hello',
4};

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.

1const useGlobalState = () => {
2 return globalState;
3};

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.

1const listeners = new Set();
2
3const useGlobalState = () => {
4 const [state, setState] = useState(globalState);
5 useEffect(() => {
6 const listener = () => {
7 setState(globalState);
8 };
9 listeners.add(listener);
10 listener(); // in case it's already changed
11 return () => listeners.delete(listener); // cleanup
12 }, []);
13 return state;
14};

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.

1const setGlobalState = (nextGlobalState) => {
2 globalState = nextGlobalState;
3 listeners.forEach(listener => listener());
4};

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

1const useGlobalState = () => {
2 const [state, setState] = useState(globalState);
3 useEffect(() => {
4 // ...
5 }, []);
6 return [state, setGlobalState];
7};

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.

1const createContainer = (initialState) => {
2 let globalState = initialState;
3 const listeners = new Set();
4
5 const setGlobalState = (nextGlobalState) => {
6 globalState = nextGlobalState;
7 listeners.forEach(listener => listener());
8 };
9
10 const useGlobalState = () => {
11 const [state, setState] = useState(globalState);
12 useEffect(() => {
13 const listener = () => {
14 setState(globalState);
15 };
16 listeners.add(listener);
17 listener(); // in case it's already changed
18 return () => listeners.delete(listener); // cleanup
19 }, []);
20 return [state, setGlobalState];
21 };
22
23 return {
24 setGlobalState,
25 useGlobalState,
26 };
27};

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.

1const createContainer = (initialState) => {
2 let globalState = initialState;
3 const listeners = Object.fromEntries(Object.keys(initialState).map(key => [key, new Set()]));
4
5 const setGlobalState = (key, nextValue) => {
6 globalState = { ...globalState, [key]: nextValue };
7 listeners[key].forEach(listener => listener());
8 };
9
10 const useGlobalState = (key) => {
11 const [state, setState] = useState(globalState[key]);
12 useEffect(() => {
13 const listener = () => {
14 setState(globalState[key]);
15 };
16 listeners[key].add(listener);
17 listener(); // in case it's already changed
18 return () => listeners[key].delete(listener); // cleanup
19 }, []);
20 return [state, (nextValue) => setGlobalState(key, nextValue)];
21 };
22
23 return {
24 setGlobalState,
25 useGlobalState,
26 };
27};

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.

1// ...
2
3 const setGlobalState = (key, nextValue) => {
4 if (typeof nextValue === 'function') {
5 globalState = { ...globalState, [key]: nextValue(globalState[key]) };
6 } else {
7 globalState = { ...globalState, [key]: nextValue };
8 }
9 listeners[key].forEach(listener => listener());
10 };
11
12 // ...

Step 6: Reducer

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

1const createContainer = (reducer, initialState) => {
2 let globalState = initialState;
3 const listeners = Object.fromEntries(Object.keys(initialState).map(key => [key, new Set()]));
4
5 const dispatch = (action) => {
6 const prevState = globalState;
7 globalState = reducer(globalState, action);
8 Object.keys((key) => {
9 if (prevState[key] !== globalState[key]) {
10 listeners[key].forEach(listener => listener());
11 }
12 });
13 };
14
15 // ...
16
17 return {
18 useGlobalState,
19 dispatch,
20 };
21};

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.

1const useGlobalStateProvider = () => {
2 const [state, dispatch] = useReducer(patchedReducer, globalState);
3 useEffect(() => {
4 linkedDispatch = dispatch;
5 // ...
6 }, []);
7 const prevState = useRef(state);
8 Object.keys((key) => {
9 if (prevState.current[key] !== state[key]) {
10 // we need to pass the next value to listener
11 listeners[key].forEach(listener => listener(state[key]));
12 }
13 });
14 prevState.current = state;
15 useEffect(() => {
16 globalState = state;
17 }, [state]);
18 };

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.

Frontend Monitoring

Asayer is a frontend monitoring tool that replays everything your users do and shows how your web app behaves for every issue. It lets you reproduce issues, aggregate JS errors and monitor your web app’s performance.

Happy debugging, for modern frontend teams - Start monitoring your web app for free.

More articles from Asayer Blog

How Browser Rendering Works and Why You Should Care

This article gives an overview on the browser page rendering process and explains the basics of efficient javascript animation.

August 7th, 2020 · 3 min read

How to Write Better TypeScript

Learn how to make better use of Typescript's strengths and features while involving React.

July 31st, 2020 · 8 min read
© 2020 Asayer Blog
Link to $https://twitter.com/asayerioLink to $https://github.com/asayerioLink to $https://www.linkedin.com/company/18257552