useGlobalState()

Sharing state between React components without context or fancy libraries

Ever wanted to share a state between multiple components without using context or fancy libraries? Here's a simple hook that does just that.

But first, a bit of overview about state sharing.

The global state problem

Since React's state hooks (useState() and useReducer()) operate within the scope of a single component, you cannot use them to share state between components. Even if you create your own hook that uses useState internally, and use that hook in multiple components, each one will get its own state instance.

Prop drilling

One way to solve this issue is by lifting up the state to a common ancestor and then passing the state as a prop to its descendants.

This may be OK if you have a very shallow component tree, but as your tree gets more complex, prop drilling becomes a less viable option.

In prop drilling, you're forcing every level of your tree to be aware of this otherwise unrelated piece of information. This adds complexity and introduces redundant responsibility (I highly recommend Rich Hickey's Simplicity Matters keynote about complexity in software).

Not to mention that changing the state will cause all the components in the path from the common ancestor to the consumer to rerender.

Context Hell

As a way to remediate prop drilling, React introduced the concept of Context. This is a good solution for basic cases, but it doesn't scale up well if you have more than a few shareable states, as you end up with many nested contexts:

jsx
<SharedStateContext1>
    <SharedStateContext2>
        <SharedStateContext3>
            // Etc...
        </SharedStateContext2>
    </SharedStateContext2>
</SharedStateContext1>

You may ask yourself: Why not just use a single context for all the shared states? The problem with this approach is that every update to any of the shared states will cause all of the consumers to rerender. You can't selectively update some of the consumers; Once you update the provider's value, all the consumers will rerender. This is a big performance hit, especially if you have many consumers.

Another issue with context is that the common ancestor (the provider) must be updated (rerendered) when the shared state changes. You can't just update the descendants that share the state.

If your tree is not properly optimized using memo or PureComponent, this can lead to performance issues.

The solution

The solution is to use a variation of the observable pattern:

We can store the data (Subject) and a list of listeners (Observers) in global variables. Once the hook is called, register a new listener. When any of the components using the hook call setState(), signal all the listeners. To support multiple states, we can use a unique identifier such as a string, or - as I'll show later - Symbol().

js
const store = {};
const listeners = {};

function useGlobalState(key, initialValue) {
    const [state, _setState] = useState(store[key] || initialValue);
    const setState = useCallback(stateOrSetter => {
        let next = stateOrSetter;
        if (typeof stateOrSetter === 'function') {
            next = stateOrSetter(store[key]);
        }
        listeners[key].forEach(l => l(next));
        store[key] = next;
    }, []);

    useEffect(() => {
        // Store the initial state on the first call with this key
        if (!store[key]) {
            store[key] = initialValue;
        }
        // Create an empty array of listener on the first call with this key
        if (!listeners[key]) {
            listeners[key] = [];
        }
        // Register the observer
        const listener = state => _setState(state);
        listeners[key].push(listener);

        // Cleanup when unmounting
        return () => {
            const index = listeners[key].indexOf(listener);
            listeners[key].splice(index, 1);
        };
    }, [])

    return [state, setState];
}

The usage is quite simple, and is almost identical to React's useState:

js
const [state, setState] = useGlobalState('someUniqueKey', INITIAL_STATE);

Any component that calls useGlobalState with the same key will get the same state.

Using Symbol() as key

Having to come up with a unique string key for every state can be annoying, so instead we can use Symbol(), which is guaranteed to be unique!

js
const TODOS_KEY = Symbol();
const useTodos = () => useGlobalState(TODOS_KEY, INITIAL_STATE);

// Usage
const [state, setState] = useTodos();

Simplifying the solution

We can slightly simplify the usage and implementation by introducing a generator function. This also helps us avoid the whole key concept. We'll call it createState():

js
const TODOS = createState(INITIAL_STATE);
const useTodos = () => useGlobalState(TODOS);

To support that we need to make some changes to the original implementation:

js
function createState(initialValue) {
    return {
        listeners: [],
        state: initialValue,
    };
}

function useGlobalState(config) {
    const [state, _setState] = useState(config.state);
    const setState = useCallback(stateOrSetter => {
        let next = stateOrSetter;
        if (typeof stateOrSetter === 'function') {
            next = stateOrSetter(config.state);
        }
        config.listeners.forEach(l => l(next));
        config.state = next;
    }, []);

    useEffect(() => {
        // Register the observer
        const listener = state => _setState(state);
        config.listeners.push(listener);

        // Cleanup when unmounting
        return () => {
            const index = config.listeners.indexOf(listener);
            config.listeners.splice(index, 1);
        };
    }, [])

    return [state, setState];
}

Example

In this simple example we share a global count state between two components. Incrementing one counter also updates the other. Check it out!

Code Playground

Integrating useSyncExternalStore()

React 18 introduces a new hook called useSyncExternalStore(), which lets you synchronize state between React and external stores (thanks Fernando for bringing this up!).

We can use this hook to replace both useState and useEffect, but the idea stays largely the same - we still need to store the state and the listeners in global variables, and emit the changes to the listeners:

js
export function createState(initialValue) {
    return {
        listeners: [],
        state: initialValue,
    };
}

export function useGlobalState(config) {
    const setState = useCallback(stateOrSetter => {
        let next = stateOrSetter;
        if (typeof stateOrSetter === 'function') {
            next = stateOrSetter(config.state);
        }
        config.state = next;
        config.listeners.forEach(l => l());
    }, []);

    const state = useSyncExternalStore(
        (listener) => {
            // Register the observer
            config.listeners.push(listener);

            // Cleanup when unmounting
            return () => config.listeners.filter(l => l !== listener);
        },
        () => config.state,
    );
    return [state, setState];
}

Here's the same example as before, but using useSyncExternalStore():

Code Playground
Spread the word about this awesome stuff!

A newsletter for front-end web developers

Stay up-to-date with my latest articles, experiments, tools, and much more!

Issued monthly (or so). No spam. Unsubscribe any time.