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:
<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()
.
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
:
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!
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()
:
const TODOS = createState(INITIAL_STATE);
const useTodos = () => useGlobalState(TODOS);
To support that we need to make some changes to the original implementation:
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!
#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:
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()
: