Application State Management
Gleb Irovich
Posted on March 11, 2021
Welcome, everyone! In today's post, I would like to talk about the application state management. We will discuss what the state is and build a bare-bone state management solution with Typescript.
What is the state and why we need it?
Application state is a piece of information held together and can be accessed from different parts of your application. Data stored in the state is a snapshot of your program's dynamic properties in a given moment.
Why do we need it?
- State helps to keep pieces of application in sync
- Centralization makes an application more maintainable and the code more readable
Simple state
In an extremely simplified version, a state is just a JavaScript object. The state has some properties that different consumers can access. In the example below, our state keeps track of the count. stateConsumerA
mutates the state by incrementing the count, while stateConsumerB
logs state to the console.
interface State {
count: number;
}
const state: State = {
count: 0
};
function stateConsumerA() {
state.count++;
}
function stateConsumerB() {
console.log(state);
}
stateConsumerA();
stateConsumerB(); // log: {count: 1}
What can we do better? One of the important requirements for the state is immutability. Immutability helps to prevent some undesired side-effects, which the mutation can cause. Moreover, immutability allows comparing different state snapshots to decide if an expensive operation should be performed.
Immutable state
Imagine your application being a public library and your state being a sacred book. As a library, you are willing to share this book's content, but you don't want it to be damaged. Therefore when someone requests that book, you send this person a copy.
Immutability in JavaScript is also achieved by creating a copy.
Consider an example below. We use an IIFE to encapsulate the application state in the closure and expose methods to read and update the state.
interface State {
count: number;
}
interface StateStore {
getState(): State;
increment(): void;
}
const stateStore: StateStore = (function(): StateStore {
const _state: State = {
count: 0
};
return {
getState: () => ({ ..._state }),
increment: () => {
_state.count++;
}
};
})();
function stateConsumerA() {
stateStore.increment(); // original state count is incremented by one
stateStore.getState().count = 100; // original state count is not mutated
}
function stateConsumerB() {
console.log(stateStore.getState());
}
stateConsumerA();
stateConsumerB(); // log: {count: 1}
You might notice that instead of returning actual state value, we create its shallow copy. Therefore, when stateConsumerA
attempts to mutate the state object, it does not affect the output from the stateConsumerB
.
One could alternatively implement it using ES6 classes, which will be our preferred approach for the rest of that post.
class Store {
private state: State = {
count: 0
};
public getState(): State {
return { ...this.state };
}
public increment() {
this.state.count++;
}
}
const stateStore = new Store();
Subscribing to state updates
Now, as you got an idea of what the state actually is, you might be wondering:
"OK, now I can update the state. But how do I know when the state was updated?".
The last missing piece is of cause subscribing to state updates. This is probably one reason why someone would bother about state management - to keep the application in sync.
There are a lot of brilliant state management solutions out there. But most of them have something in common - they rely on the Observer Pattern.
The concept is simple but though powerful. Subject keeps track of the state and its updates. Observers (in our case, state consumers) are attached to the subject and notified whenever the state changes.
type Observer = (state: State) => void;
An observer, in our case, is just a function that takes State
as an input and performs some operations with this state.
Let's create an Observer
that logs if count
is odd or even:
function observer(state: State) {
const isEven = state.count % 2 === 0;
console.log(`Number is ${isEven ? "even" : "odd"}`);
}
Now we need to rework our Store
class.
class Store {
private state: State = {
count: 0
};
private observers: Observer[] = [];
public getState(): State {
return { ...this.state };
}
public increment() {
this.state.count++;
this.notify(); // We need to notify observers whenever state changes
}
public subscribe(observer: Observer) {
this.observers.push(observer);
}
private notify() {
this.observers.forEach(observer => observer(this.state));
}
}
Let's look at this example. Store
, our Subject, contains information about the state and allows subscribing observers to the updates by adding them to the list and invoking with the latest state snapshot when it changes.
Here it is in action:
const stateStore = new Store();
stateStore.subscribe(observer);
stateStore.increment();
stateStore.increment();
stateStore.increment();
Our code will produce the following output:
Number is odd
Number is even
Number is odd
Although we haven't called our observer function, Subject does its job by notifying observers and calling them with the latest state snapshot.
Last but not least
The example discussed in this post is not exhaustive. In the real-world scenario, you should also take performance into account and unsubscribe, or detach observers, when necessary.
class Store {
...
public unsubscribe(observer: Observer) {
this.observers = this.observers.filter(item => item !== observer);
}
...
}
Conclusion
State management is an important topic. We deal with it regardless of the technology, and therefore I think it's important to know how it works under the hood.
Let me know if you find this topic interesting, and I will be happy to elaborate on this in the future.
If you liked my posts, please spread the word and follow me on Twitter 🚀 and DEV.to for more exciting content about web development.
Posted on March 11, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.