One-way state management in vanilla JavaScript
Ever wonder what were the basic building blocks of one-way state management libraries such as redux
or vuex
? Well, you are in the right place as we will be looking at re-implementing one-way state management in vanilla JavaScript.
For the purpose of this article, we will be building a basic counter, with a button to increment the counter, a button to decrement the counter, and a button to reset the counter.
The basic markup we will be working with is the following:
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Counter</title>
</head>
<body>
<p id="counter"></p>
<button id="increment">+</button>
<button id="reset">Reset</button>
<button id="decrement">-</button>
<script src="main.js"></script>
</body>
</html>
The goal is to look at different implementations of managing the state of the counter.
Let's first start with a naive implementation:
main.js
window.addEventListener("DOMContentLoaded", ignite);
function ignite() {
const $counter = document.querySelector("#counter");
const $increment = document.querySelector("#increment");
const $decrement = document.querySelector("#decrement");
const $reset = document.querySelector("#reset");
const state = {
counter: 0,
};
$increment.addEventListener("click", () => {
state.counter = state.counter + 1
$counter.innerText = state.counter;
});
$decrement.addEventListener("click", () => {
state.counter = state.counter - 1
$counter.innerText = state.counter;
});
$reset.addEventListener("click", () => {
state.counter = 0
$counter.innerText = state.counter;
});
}
We are attaching event listeners on each button, and mutating the counter
field of a state
object that is in scope of all the event handlers. This works fine, but we are already seeing a few places where this code might not scale so well.
The most obvious one is that we need to set the counter's inner text in each handler:
$counter.innerText = state.counter;
It would be great if we could abstract that away in a function, such as:
function updateUI() {
$counter.innerText = state.counter;
}
Now our overall code looks like follows:
window.addEventListener("DOMContentLoaded", ignite);
function ignite() {
const $counter = document.querySelector("#counter");
const $increment = document.querySelector("#increment");
const $decrement = document.querySelector("#decrement");
const $reset = document.querySelector("#reset");
function updateUI() {
$counter.innerText = state.counter;
}
const state = {
counter: 0,
};
$increment.addEventListener("click", () => {
state.counter = state.counter + 1;
updateUI();
});
$decrement.addEventListener("click", () => {
state.counter = state.counter - 1;
updateUI();
});
$reset.addEventListener("click", () => {
state.counter = 0;
updateUI();
});
}
This is an improvement as we only need to update the updateUI()
function if we scale the counter and need to make more changes to the UI when the counter's value updates, but this is not yet as DRY as I could be ...
Enter, Proxies
!
To automatically make a call to updateUI()
whenever any field in the state
gets updated, we will wrap the state
object in a Proxy
:
const state = new Proxy(
{
counter: 0,
},
{
set(obj, prop, value) {
obj[prop] = value;
updateUI();
},
}
);
Now, every time that a field in the state
gets update, we will call updateUI()
. This leaves us with the following code:
window.addEventListener("DOMContentLoaded", ignite);
function ignite() {
const $counter = document.querySelector("#counter");
const $increment = document.querySelector("#increment");
const $decrement = document.querySelector("#decrement");
const $reset = document.querySelector("#reset");
function updateUI() {
$counter.innerText = state.counter;
}
const state = new Proxy(
{
counter: 0,
},
{
set(obj, prop, value) {
obj[prop] = value;
updateUI();
},
}
);
$increment.addEventListener("click", () => {
state.counter = state.counter + 1;
});
$decrement.addEventListener("click", () => {
state.counter = state.counter - 1;
});
$reset.addEventListener("click", () => {
state.counter = 0;
});
}
Alright, that looks pretty neat ... but having direct mutations to the state
still doesn't look that scalable and easy to reason about once we start adding more complex asynchronous interactions.
This is where one-way state management libraries really shine. Sure, it's a lot of boilerplate, and it might not make sense for simple (even asynchronous) applications, but it also brings predictability while managing state.
Ok, so let's go step by step. In most one-way state management libraries, there is a central store
which has a private state
and exposes a dispatch()
and a getState()
function. To mutate the state
, we dispatch()
actions, which call the main reducer()
to produce the next state
depending on the actual value and the action being dispatched. The state
cannot be mutated outside of the store
.
To achieve such a design, we have to create a closure around a state
object, by first building a function that will create a store:
function createStore(initialState, reducer) {
const state = new Proxy(
{ value: initialState },
{
set(obj, prop, value) {
obj[prop] = value;
updateUI();
},
}
);
function getState() {
// Note: this only works if `initialState` is an Object
return { ...state.value };
}
function dispatch(action) {
const prevState = getState();
state.value = reducer(prevState, action);
}
return {
getState,
dispatch,
};
}
Here, we have moved our previous proxied version of state
inside the createStore()
function, which accepts 2 arguments: the initial value of state
and the main reducer
used to calculate the next state depending on the dispatched action.
It returns an object with a getState()
function, which returns an "unproxied" value for state
. Among other things, this ensures that state
is never mutated outside of the reducer()
as the value returned is not the actual state
held by the store
.
The dispatch()
function, takes in an action
and calls the main reducer()
with the previous value of state
and said action
, then assigns the newly returned state
.
In our case, we can define the initalState
and the reducer()
as follows:
const initialState = { counter: 0 };
function reducer(state, action) {
switch (action) {
case "INCREMENT":
state.counter = state.counter + 1;
break;
case "DECREMENT":
state.counter = state.counter - 1;
break;
case "RESET":
default:
state.counter = 0;
break;
}
return state;
}
Note that in our case, reducers are pure functions, so they need to return the new value of the state
.
Finally, we initialize the store
, and make the necessary changes to our event handlers and updateUI()
function:
const store = createStore(initialState, reducer);
function updateUI() {
$counter.innerText = store.getState().counter;
}
$increment.addEventListener("click", () => {
store.dispatch("INCREMENT");
});
$decrement.addEventListener("click", () => {
store.dispatch("DECREMENT");
});
$reset.addEventListener("click", () => {
store.dispatch("RESET");
});
All together, our homemade one-way state management in vanilla JavaScript to handle a counter looks like this:
main.js
window.addEventListener("DOMContentLoaded", ignite);
function ignite() {
const $counter = document.querySelector("#counter");
const $increment = document.querySelector("#increment");
const $decrement = document.querySelector("#decrement");
const $reset = document.querySelector("#reset");
function createStore(initialState, reducer) {
const state = new Proxy(
{ value: initialState },
{
set(obj, prop, value) {
obj[prop] = value;
updateUI();
},
}
);
function getState() {
// This only works if `initialState` is an Object
return { ...state.value };
}
function dispatch(action) {
const prevState = getState();
state.value = reducer(prevState, action);
}
return {
getState,
dispatch,
};
}
const initialState = { counter: 0 };
function reducer(state, action) {
switch (action) {
case "INCREMENT":
state.counter = state.counter + 1;
break;
case "DECREMENT":
state.counter = state.counter - 1;
break;
case "RESET":
default:
state.counter = 0;
break;
}
return state;
}
const store = createStore(initialState, reducer);
function updateUI() {
$counter.innerText = store.getState().counter;
}
$increment.addEventListener("click", () => {
store.dispatch("INCREMENT");
});
$decrement.addEventListener("click", () => {
store.dispatch("DECREMENT");
});
$reset.addEventListener("click", () => {
store.dispatch("RESET");
});
}
Of course, libraries like redux
or vuex
take care of a lot of edge cases that we have overlooked, and add a lot more to the mix than just the concepts we have touched upon in the article, but hopefully that gives you a good idea of the logic behind some popular one-way state management libraries.