Using JavaScript module system for state management
Hot topic last couple of years is state management. Especially in the front-end apps. There are lots of problems and lots of solutions. One thing thought is totally ignored in this context - the JavaScript module system. I'm very often reaching out to this approach and decided to share it here.
What we actually need when dealing with state
Let's first define the most important characteristics of a state management tool.
- We should have access to the state from every point in our application.
- We should be able to change the state easily.
- We should have a mechanism for subscribing. Or in other words we should be able to execute logic when the state changes. Very often this is rendering.
And now we will try to cover those using the JavaScript module system.
Accessing the state
The module system in JavaScript has one very important feature - it caches the content of the module. Or to be more specific, it caches the stuff defined in the root scope of the file. Let's say that we have the following state.js
:
// state.js
const State = {
value: 0,
add(n) {
this.value += n;
}
};
export default State;
When we import
this file, we will get always the same State
object. For example:
// A.js
import state from "./state";
state.add(2);
// B.js
import state from "./state";
state.add(3);
Here state
is identical and if on a third place we render state.value
the value will be 5.
import state from "./state";
import "./A";
import "./B";
function render() {
// This will print out "5" on the screen.
document.getElementById("app").innerHTML = state.value;
}
render();
This works because State
is defined into the "global" state of the state.js
file and as such is cached into the module system. This opens the door for various solutions based on the Singleton pattern.
Changing the state
When changing the state there is one very important thing - the amendment needs to happen via setter. We can't directly modify the value because if we do so we can't notify the rest of the application.
There are of course couple of options here. If we don't want to use a function we may rely on the MutationObserver API or use a Proxy. This doesn't really matter. The main idea is to have control on the actual state value assignment.
The subscription mechanism
This could happen easily by implementing some variants of the Publish–subscribe pattern. For example:
let listeners = [];
const State = {
value: 0,
add(n) {
this.value += n;
listeners.forEach((c) => c());
},
listen(cb) {
listeners.push(cb);
return () => {
listeners = listeners.filter((c) => c !== cb);
};
}
};
We can add a listener and every time when the value is changed we will call it. The listen
method also returns a clean up function. Once fired our listeners will be removed from the list. The example app code above may be changed like so:
import state from "./state";
import "./A";
import "./B";
function render() {
// We will get "5" and two seconds later "125".
document.getElementById("app").innerHTML = state.value;
}
state.listen(render);
setTimeout(() => {
state.add(120);
}, 2000);
render();
After the first render we will get 5 on the page. Then, two seconds later we will see 125. Here is an online demo https://codesandbox.io/s/wonderful-dream-vg9ge.