Introduction
First of all I'd like to talk a little bit about state management in react. State management in react can be divided into two parts
- Local state management
- Global state management
Local states are used when we're dealing with states which are not shared among two or more components(i.e they are used within a single component)
Global states are used when components need to share states.
React provides a very good and simple way to manage local state(React hooks) but when it comes to global state management the options available are overwhelming. React itself provides the context API which many third party libraries for managing global state are built on top of it, but still the APIs built are not as simple and intuitive as react state hooks, let alone the cons of using the context API to manage global state which we won't be discussing in this post, but there are plenty of articles talking about it, so check them out if you want to explore deeper.
So what's new?
Today I want to introduce a different approach on managing global state in react which I think it might allow us to build simple and intuitive API for managing global state just like hooks API.
The concept of managing states comes from the concept of variables which is very basic in all programming languages. In managing state we have local and global states which corresponds to local and global variables in the concept of variables. In both concepts the purpose of global(state & variable) is to allow sharing it among entities which might be functions, classes, modules, components etc, while the purpose of local(state & variable) is to restrict its usage to the scope where it has been declared which might also be a function, a class, a module, a component etc.
So these two concepts have a lot in common, this made me ask my self a question
"What if we could be able to use global variables to store global states in react?".
So I decided to experiment it.
Show me the code
I started by writing a very simple and probably a dumb example as shown below
import React from 'react';
// use global variable to store global state
let count = 0;
function Counter(props){
let incrementCount = (e) => {
++count;
console.log(count);
}
return (
<div>
Count: {count}
<br/>
<button onClick={incrementCount}>Click</button>
</div>
);
}
ReactDOM.render(<Counter/>, document.querySelector("#root"));
As you might have guessed this example renders count: 0
but if you click to increment, the value of count
rendered doesn't change, but the one printed on a console changes. So why this happens despite the fact that we have only one count
variable?.
Well this happens because when we click, the value of count
increments(that's why it prints incremented value on a console) but the component Counter
doesn't re-render to get the latest value of count
.
So that's what we are missing to be able to use our global variable count
to store a global state. Let's try to solve this by re-rendering our component when we update our global variable. Here we are going to use useState
hook to force our component to re-render so that it gets a new value.
import React from 'react';
// use global variable to store global state
let count = 0;
function Counter(props){
const [,setState] = useState();
let incrementCount = (e) => {
++count;
console.log(count);
// Force component to re-render after incrementing `count`
// This is hack but bare with me for now
setState({});
}
return (
<div>
Count: {count}
<br/>
<button onClick={incrementCount}>Click</button>
</div>
);
}
ReactDOM.render(<Counter/>, document.querySelector("#root"));
So this works, it'll basically re-render every time you click.
I know, I know this is not a good way to update a component in react but bare with me for now. We were just trying to use global variable to store global state and it just worked so let's just cerebrate this for now.
Okay now let's continue...
What if components need to share state?
Let's first refer to the purpose of global state,
"Global states are used when components need to share states".
In our previous example we have used count
global state in only one component, now what if we have a second component in which we would like to use count
global state too?.
Well let's try it
import React from 'react';
// use global variable to store global state
let count = 0;
function Counter1(props){
const [,setState] = useState();
let incrementCount = (e) => {
++count;
setState({});
}
return (
<div>
Count: {count}
<br/>
<button onClick={incrementCount}>Click</button>
</div>
);
}
function Counter2(props){
const [,setState] = useState();
let incrementCount = (e) => {
++count;
setState({});
}
return (
<div>
Count: {count}
<br/>
<button onClick={incrementCount}>Click</button>
</div>
);
}
function Counters(props){
return (
<>
<Counter1/>
<Counter2/>
</>
);
}
ReactDOM.render(<Counters/>, document.querySelector("#root"));
Here we have two components Counter1
& Counter2
, they are both using counter
global state. But when you click the button on Counter1
it will update the value of count
only on Counter1
. On counter2
it will remain 0. Now when you click the button on Counter2
it updates but it jumps from zero to the last value on Counter1
plus one. If you go back to the Counter1
it does the same, jump from where it ended to the last value on Counter2
plus one.
Mmmmmmmh this is weird, what might be causing that?..
Well the reason for this is, when you click the button on Counter1
it increments the value of count
but it re-renders only Counter1
, since Counter1
and Counter2
doesn't share a method for re-rendering, each has its own incrementCount
method which runs when the button in it is clicked.
Now when you click Counter2
the incrementCount
in it runs, where it takes the value of count
which is already incremented by Counter1
and increment it, then re-render, that's why the value of count jumps to the last value on Counter1
plus one. If you go back to Counter1
the same thing happen.
So the problem here is, when one component updates a global state other components sharing that global state doesn't know, the only component which knows is the one updating that global state. As a result when the global state is updated other components which share that global state won't re-render.
So how do we resolve this?....
It seems impossible at first but if you take a look carefully you will find a very simple solution.
Since the global state is shared, the solution to this would be to let the global state notify all the components(sharing it) that it has been updated so all of them need to re-render.
But for the global state to notify all components using it(subscribed to it), it must first keep track of all those components.
So to simplify the process will be as follows
Create a global state(which is technically a global variable)
Subscribe a component(s) to a created global state(this lets the global state keep track of all components subscribed to it)
If a component wants to update a global state, it sends update request
When a global state receives update request, it performs the update and notify all components subscribed to it for them to update themselves(re-render)
Here is the architectural diagram for more clarification
You are probably already familiar with this design pattern, it's quite popular, it's called Observer Design Pattern.
With this and a little help from hooks, we'll be able to manage global state completely with global variables.
Let's start by implementing our global state
function GlobalState(initialValue) {
this.value = initialValue; // Actual value of a global state
this.subscribers = []; // List of subscribers
this.getValue = function () {
// Get the actual value of a global state
return this.value;
}
this.setValue = function (newState) {
// This is a method for updating a global state
if (this.getValue() === newState) {
// No new update
return
}
this.value = newState; // Update global state value
this.subscribers.forEach(subscriber => {
// Notify subscribers that the global state has changed
subscriber(this.value);
});
}
this.subscribe = function (itemToSubscribe) {
// This is a function for subscribing to a global state
if (this.subscribers.indexOf(itemToSubscribe) > -1) {
// Already subsribed
return
}
// Subscribe a component
this.subscribers.push(itemToSubscribe);
}
this.unsubscribe = function (itemToUnsubscribe) {
// This is a function for unsubscribing from a global state
this.subscribers = this.subscribers.filter(
subscriber => subscriber !== itemToUnsubscribe
);
}
}
From the implementation above, creating global state from now on will be as shown below
const count = new GlobalState(0);
// Where 0 is the initial value
So we're done with global state implementation, to recap what we've done in GlobalState
We have created a mechanism to subscribe & unsubscribe from a global state through
subscribe
&unsubscribe
methods.We have created a mechanism to notify subscribers through
setValue
method when a global state is updatedWe have created a mechanism to obtain global state value through
getValue
method
Now we need to implement a mechanism to allow our components to subscribe, unsubscribe, and get the current value from GlobalState
.
As stated earlier, we want our API to be simple to use and intuitive just like hooks API. So we are going to make a useState
like hook but for global state.
We are going to call it useGlobalState
.
Its usage will be like
const [state, setState] = useGlobalState(globalState);
Now let's write it..
import { useState, useEffect } from 'react';
function useGlobalState(globalState) {
const [, setState] = useState();
const state = globalState.getValue();
function reRender(newState) {
// This will be called when the global state changes
setState({});
}
useEffect(() => {
// Subscribe to a global state when a component mounts
globalState.subscribe(reRender);
return () => {
// Unsubscribe from a global state when a component unmounts
globalState.unsubscribe(reRender);
}
})
function setState(newState) {
// Send update request to the global state and let it
// update itself
globalState.setValue(newState);
}
return [State, setState];
}
That's all we need for our hook to work. The very important part of useGlobalState
hook is subscribing and unsubscribing from a global state. Note how useEffect
hook is used to make sure that we clean up by unsubscribing from a global state to prevent a global state from keeping track of unmounted components.
Now let's use our hook to rewrite our example of two counters.
import React from 'react';
// using our `GlobalState`
let globalCount = new GlobalState(0);
function Counter1(props){
// using our `useGlobalState` hook
const [count, setCount] = useGlobalState(globalCount);
let incrementCount = (e) => {
setCount(count + 1)
}
return (
<div>
Count: {count}
<br/>
<button onClick={incrementCount}>Click</button>
</div>
);
}
function Counter2(props){
// using our `useGlobalState` hook
const [count, setCount] = useGlobalState(globalCount);
let incrementCount = (e) => {
setCount(count + 1)
}
return (
<div>
Count: {count}
<br/>
<button onClick={incrementCount}>Click</button>
</div>
);
}
function Counters(props){
return (
<>
<Counter1/>
<Counter2/>
</>
);
}
ReactDOM.render(<Counters/>, document.querySelector("#root"));
You will notice that this example works perfectly fine. When Counter1
updates Counter2
get updates too and vice versa.
This means it's possible to use global variables to manage global state. As you saw, we have managed to create a very simple to use and intuitive API for managing global state, just like hooks API. We have managed to avoid using Context API at all, so no need for Providers or Consumers.
You can do a lot with this approach, things like selecting/subscribing to deeply nested global state, persisting global state to a local storage, implement key based API for managing global state, implement useReducer
like for global state and many many more.
I myself wrote an entire library for managing global state with this approach it includes all those mentioned features, here is the link if you want to check it out https://github.com/yezyilomo/state-pool.
Thank you for making to this point, I would like to hear from you, what do you think of this approach?.
Top comments (12)
We use your
state-pool
in our production system and it works great. We don't have to worry about hard-to-maintain Context API. Thank you very much for the innovative work. Also, I wonder why React doesn't support this approach by default.Also, I've created a minified version (200 bytes gziped) of state-pool for Preact (github.com/SupunKavinda/preact-glo...), with one hook,
useGlobalState
.Btw, I saw that you are using immer as an dependency. Is it for the
reducer
?Hi @supunkavinda
I installed your package and tried to use it. But I keep getting this error.
TypeError: Cannot read properties of undefined (reading 'H')
at d (localhost:9000/runFrame/bundle.js:45827:419)
at s (localhost:9000/__runFrame/bundle.j...)
at h (localhost:9000/__runFrame/bundle.j...)
at useGlobalState (localhost:9000/__runFrame/bundle.j...)
at CareerStoryApp (localhost:9000/__runFrame/bundle.j...)
at renderWithHooks (localhost:9000/__runFrame/bundle.j...)
at mountIndeterminateComponent (localhost:9000/__runFrame/bundle.j...)
at beginWork (localhost:9000/__runFrame/bundle.j...)
at HTMLUnknownElement.callCallback (localhost:9000/__runFrame/bundle.j...)
at Object.invokeGuardedCallbackDev (localhost:9000/__runFrame/bundle.j...)
at invokeGuardedCallback (localhost:9000/__runFrame/bundle.j...)
at beginWork$1 (localhost:9000/__runFrame/bundle.j...)
at performUnitOfWork (localhost:9000/__runFrame/bundle.j...)
at workLoopSync (localhost:9000/__runFrame/bundle.j...)
at performSyncWorkOnRoot (localhost:9000/__runFrame/bundle.j...)
at scheduleUpdateOnFiber (localhost:9000/__runFrame/bundle.j...)
at updateContainer (localhost:9000/__runFrame/bundle.j...)
at localhost:9000/__runFrame/bundle.j...
at unbatchedUpdates (localhost:9000/__runFrame/bundle.j...)
at legacyRenderSubtreeIntoContainer (localhost:9000/__runFrame/bundle.j...)
at Object.render (localhost:9000/__runFrame/bundle.j...)
at initializeBlock (localhost:9000/__runFrame/bundle.j...)
at ./frontend/index.js (localhost:9000/__runFrame/bundle.j...)
at webpack_require (localhost:9000/__runFrame/bundle.j...)
at runBlock (localhost:9000/__runFrame/bundle.j...)
at static.airtable.com/js/by_sha/a663...
Hey, I am no longer maintaining the package. Also, from my experience, global state using window variables can run into many pitfalls. Use nanostores if possible.
Hi, thanks for your fast reply.
Okay, I will consider using nanostores.
But when you use state-pool package, did you run without any errors?
I get keeping this error.
"TypeError: Cannot read properties of undefined (reading 'setState')"
This error indicates store.setState("count", 0);
Can you please guide me?
Thank you!
Sorry, I haven't used it in a long while
Okay anyway.
Thanks!
Immer is used for object comparisons to make sure that even a small change in object property triggers re-render to all components subscribed to a related global state.
haven't checked the lib implementation but checking whether the new value is different then the old with
===
doesn't work on objects (just checks the heap reference). would this be desirable?Lib implementation depends on immer to compare objects, so it works just fine.
Don't use this in production please. It does not work perfectly with react and is not implemented correctly. React recommends useSyncExternalStore for library authors maybe you should take a look.
Yezy nice post,
In your reRender(newState) function you give it a newState parameter not sure how this is used. How would go wrong if the function doesn't have that parameter?
Thanks for the feedback, I haven't showed its purpose in this post so you can safely ignore it but the intention was to pass it in case you want to avoid re-rendering if the global value hasn't changed, which in that case you would compare the old value and the new value and decide weather to rerender or not, so in short its purpose is to avoid unnecessary rerenders which is something I haven't showed in this post. You can check how I've used it in my library I mentioned to accomplish that.