My way to a beautifully minimal shared state setup in React
Disclaimer: The short path I'm about to walk us through can challenge the faith in the shared state full of reducers, slices, atoms, or observables. ~ I'm starting off with a local state setup with React's useState(). It would be handy if the shared state setup were similar to the local state setup for the following reasons: Shared state often evolves from local state (it's a common and recommended course of things), so it would be nice to have a short path of migration from local state to shared state (or vice versa, if need be) to spare the time for other things in life; The React's local state setup is a common pattern for dealing with a component's state already familiar to React devs, sticking to similar APIs for semantically similar concepts reduces the cognitive load and leads to a positive developer experience. The following example shows what a local state setup with React's useState() looks like: const Counter = () => { const [count, setCount] = useState(0); const handleClick = useCallback(() => { setCount(value => value + 1); }, [setCount]); return {value}; }; Let's move the initial state value to a shared location, that is to a React Context: + // shared initial state + const AppContext = createContext(0); const Counter = () => { - const [count, setCount] = useState(0); + const [count, setCount] = useState(useContext(AppContext)); const handleClick = useCallback(() => { setCount(value => value + 1); }, [setCount]); return {value}; }; That's not a controllable shared state yet. The value from the Context only affects the initial value of the local state (hence the term initial state). The value setter setCount() doesn't affect the value in the Context and the updates aren't visible to other components reading the value from the Context. If we could make the shared initial state interactive, that is responsive to changes, effectively we would turn it into the controllable shared state, that we're after. Looks like a way to go. We'll devise: a container for the initial state, let's call it a store, providing a state setter out of the box (instead of manually adding a value setter to the Context), and a useStore() hook that will unpack the current state value from the store and subscribe the component to changes in the store in order to make the component responsive to these changes. - // shared initial state - const AppContext = createContext(0); + // controllable shared state + const AppContext = createContext(new Store(0)); const Counter = () => { - const [count, setCount] = useState(useContext(AppContext)); + const [count, setCount] = useStore(useContext(AppContext)); const handleClick = useCallback(() => { setCount(value => value + 1); }, [setCount]); return {value}; }; Conforming to the useState()'s API, the useStore() hook returns a state value setter setCount along with the current state value count. Note that apart from replacing the hook nothing else has changed in the component. Now, calling setCount() updates the store state value, which is visible to all components subscribed to the store from AppContext with the useStore() hook, like the Counter component itself. The shared state setup is in place! With the store as a single intermediary, we've got a minimal shared state setup. Its close similarity to the local state setup with React's useState() makes the common task of migration from local state to shared state painless and makes this setup already familiar to React developers right away. Based on this idea, I created Groundstate (the link uncovers more subtle details about it). ~ In this setup, the shared state can be similarly initialized in an explicit Context Provider: - + A store can be introduced outside a React Context. Such a store can be used as a remount-persistent local state: + // remount-persistent local state + const countStore = new Store(0); const Counter = () => { - const [count, setCount] = useState(0); + const [count, setCount] = useStore(countStore); const handleClick = useCallback(() => { setCount(value => value + 1); }, [setCount]); return {value}; }; In the examples above, we had a state of a primitive type, but it can be of any type: - const AppContext = createContext({count: 0}); + const AppContext = createContext(new Store({count: 0})); Just like with React's useState(), some cases of manipulation of deeply nested data in the immutable store can be more concisely expressed in a mutable-like manner with Immer (which is not included into React and Groundstate): import {produce} from 'immer'; - // shared initial state - const AppContext = createContext({count: 0}); + // controllable shared state + const AppContext = createContext(new Store({count: 0})); const Counter = () => { - const [state, setState] = useState(useContext(AppContext)); + const [state, setState] = u

Disclaimer: The short path I'm about to walk us through can challenge the faith in the shared state full of reducers, slices, atoms, or observables.
~
I'm starting off with a local state setup with React's useState()
. It would be handy if the shared state setup were similar to the local state setup for the following reasons:
- Shared state often evolves from local state (it's a common and recommended course of things), so it would be nice to have a short path of migration from local state to shared state (or vice versa, if need be) to spare the time for other things in life;
- The React's local state setup is a common pattern for dealing with a component's state already familiar to React devs, sticking to similar APIs for semantically similar concepts reduces the cognitive load and leads to a positive developer experience.
The following example shows what a local state setup with React's useState()
looks like:
const Counter = () => {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount(value => value + 1);
}, [setCount]);
return <button onClick={handleClick}>{value}</button>;
};
Let's move the initial state value to a shared location, that is to a React Context:
+ // shared initial state
+ const AppContext = createContext(0);
const Counter = () => {
- const [count, setCount] = useState(0);
+ const [count, setCount] = useState(useContext(AppContext));
const handleClick = useCallback(() => {
setCount(value => value + 1);
}, [setCount]);
return ;
};
That's not a controllable shared state yet. The value from the Context only affects the initial value of the local state (hence the term initial state). The value setter setCount()
doesn't affect the value in the Context and the updates aren't visible to other components reading the value from the Context.
If we could make the shared initial state interactive, that is responsive to changes, effectively we would turn it into the controllable shared state, that we're after. Looks like a way to go.
We'll devise:
- a container for the initial state, let's call it a store, providing a state setter out of the box (instead of manually adding a value setter to the Context),
- and a
useStore()
hook that will unpack the current state value from the store and subscribe the component to changes in the store in order to make the component responsive to these changes.
- // shared initial state
- const AppContext = createContext(0);
+ // controllable shared state
+ const AppContext = createContext(new Store(0));
const Counter = () => {
- const [count, setCount] = useState(useContext(AppContext));
+ const [count, setCount] = useStore(useContext(AppContext));
const handleClick = useCallback(() => {
setCount(value => value + 1);
}, [setCount]);
return ;
};
Conforming to the useState()
's API, the useStore()
hook returns a state value setter setCount
along with the current state value count
. Note that apart from replacing the hook nothing else has changed in the component.
Now, calling setCount()
updates the store state value, which is visible to all components subscribed to the store from AppContext
with the useStore()
hook, like the Counter
component itself. The shared state setup is in place!
With the store as a single intermediary, we've got a minimal shared state setup. Its close similarity to the local state setup with React's useState()
makes the common task of migration from local state to shared state painless and makes this setup already familiar to React developers right away.
Based on this idea, I created Groundstate (the link uncovers more subtle details about it).
~
In this setup, the shared state can be similarly initialized in an explicit Context Provider:
-
+
A store can be introduced outside a React Context. Such a store can be used as a remount-persistent local state:
+ // remount-persistent local state
+ const countStore = new Store(0);
const Counter = () => {
- const [count, setCount] = useState(0);
+ const [count, setCount] = useStore(countStore);
const handleClick = useCallback(() => {
setCount(value => value + 1);
}, [setCount]);
return ;
};
In the examples above, we had a state of a primitive type, but it can be of any type:
- const AppContext = createContext({count: 0});
+ const AppContext = createContext(new Store({count: 0}));
Just like with React's useState()
, some cases of manipulation of deeply nested data in the immutable store can be more concisely expressed in a mutable-like manner with Immer (which is not included into React and Groundstate):
import {produce} from 'immer';
- // shared initial state
- const AppContext = createContext({count: 0});
+ // controllable shared state
+ const AppContext = createContext(new Store({count: 0}));
const Counter = () => {
- const [state, setState] = useState(useContext(AppContext));
+ const [state, setState] = useStore(useContext(AppContext));
const handleClick = useCallback(() => {
setState(
produce(draft => { draft.count++; })
);
}, [setState]);
return ;
};
Note again that there's been no change to the Immer manipulation code inside the setState()
call after moving the local state to the shared state.
The Groundstate's description covers all these common use cases, also including a multistore setup (which is perfectly legit) and the ways to fine-tune a component's responsiveness to store state updates, with some live examples.