Pre-face:
I was reading about design patters here and there, but it's finally the time to systemise them a bit.
So I started reading this great book: Design Patterns: Elements of Reusable Object-Oriented Software.
And as usual I'm remembering better if I'm writing it down.
TL;DR;
Use this pattern if you have lots of if-else's, your object needs to change its behaviour cause it's state changed, your object's state consists of multiple pieces which need to be checked often.
Definition
State design pattern allows an object to alter its behaviour when its state changes.
We have an object of type Context. It holds a reference to a state object of type IState. There are multiple implementations of IState - StateA, StateB, etc.
Context talks to the outside world and passes calls to the IState implementation it currently holds.
Concrete type of IState changes when Context's state changes.
Implementation
Let's try to write some code now.
Imagine having a Player
object, which does what players do:
- play
- stop
- forward
- rewind
class Player {
// Omitting constructor code
play(): void {}
stop(): void {}
forward(): void {}
rewind(): void {}
}
Let's start with implementing play
and stop
methods. When we 'push the button' we want to track time when this happened and of course let's log this into a console.
class Player {
// Omitting constructor code
play(): void {
this.time = Date.now();
console.log(`>>>> Time: ${this.time}`);
}
stop(): void {
this.time = Date.now();
console.log(`>>>> Time: ${this.time}`);
}
forward(): void {}
rewind(): void {}
}
Ok, we're seeing some code duplication, but let's continue for now. In real life you can't 'push the same button' twice, right? Let's add some checks.
class Player {
// Omitting constructor code
play(): void {
if (!this.isPlaying) {
this.time = Date.now();
this.isPlaying = true;
this.isStopped = false;
console.log(`>>>> Time: ${this.time}`);
}
}
stop(): void {
if (!this.isStopped) {
this.time = Date.now();
this.isStopped = true;
this.isPlaying = false;
console.log(`>>>> Time: ${this.time}`);
}
}
forward(): void {}
rewind(): void {}
}
Let's add forward
and rewind
into the mix. And let's log when 'wrong buttons are clicked'.
class Player {
// Omitting constructor code
play(): void {
if (!this.isPlaying) {
this.time = Date.now();
}
if (this.isStopped) {
console.log('>>>> [Stop]: play');
} else if (this.isForwarding) {
console.log('>>>> [Forward]: play');
} else if (this.isRewinding) {
console.log('>>>> [Rewind]: play');
} else {
console.log('>>>> [Play]: play');
}
if (!this.isPlaying) {
this.isPlaying = true;
this.isStopped = false;
this.isForwarding = false;
this.isRewinding = false;
console.log(`>>>> Time: ${this.time}`);
}
}
stop(): void {
if (!this.isStopped) {
this.time = Date.now();
}
if (this.isPlaying) {
console.log('>>>> [Play]: stop');
} else if (this.isForwarding) {
console.log('>>>> [Forward]: stop');
} else if (this.isRewinding) {
console.log('>>>> [Rewind]: stop');
} else {
console.log('>>>> [Stop]: stop');
}
if (!this.isStopped) {
this.isStopped = true;
this.isPlaying = false;
this.isForwarding = false;
this.isRewinding = false;
console.log(`>>>> Time: ${this.time}`);
}
}
forward(): void {
if (!this.isForwarding) {
this.time = Date.now();
}
if (this.isPlaying) {
console.log('>>>> [Play]: forward');
} else if (this.isStopped) {
console.log('>>>> [Stop]: forward');
} else if (this.isRewinding) {
console.log('>>>> [Rewind]: forward');
} else {
console.log('>>>> [Forward]: forward');
}
if (!this.isForwarding) {
this.isForwarding = true;
this.isStopped = false;
this.isPlaying = false;
this.isRewinding = false;
console.log(`>>>> Time: ${this.time}`);
}
}
rewind(): void {
if (!this.isRewinding) {
this.time = Date.now();
}
if (this.isPlaying) {
console.log('>>>> [Play]: rewind');
} else if (this.isStopped) {
console.log('>>>> [Stop]: rewind');
} else if (this.isForwarding) {
console.log('>>>> [Forward]: rewind');
} else {
console.log('>>>> [Rewind]: rewind');
}
if (!this.isRewinding) {
this.isRewinding = true;
this.isStopped = false;
this.isPlaying = false;
this.isForwarding = false;
console.log(`>>>> Time: ${this.time}`);
}
}
}
Well, all these if-else's are not looking good. They become longer as we add new functionality to our player and we need to keep track of all the booleans.
This is where State pattern comes into picture:
class Player extends Context<PlayerState, PlayerStateKey> {
// Omitting some setup code
constructor(state?: PlayerState) {
super();
this.state = state || new Stop(this);
}
play = () => this.state.play();
stop = () => this.state.stop();
forward = () => this.state.forward();
backward = () => this.state.backward();
}
Quite concise, right?
We're hiding actual implementations into stand-alone objects (StateA, StateB, etc.) and let our Context only take care of holding the reference to the IState.
Let's implement Play state.
class Play implements PlayerState {
key: PlayerStateKey = 'play';
constructor(private context: Player) {}
play() {
console.log('>>>> [Play]: play');
return;
}
stop() {
console.log('>>>> [Play]: stop');
this.context.time = Date.now();
this.context.changeState(new Stop(this.context));
console.log(`>>>> Time: ${this.context.time}`);
}
forward() {
console.log('>>>> [Play]: forward');
this.context.time = Date.now();
this.context.changeState(new Stop(this.context));
console.log(`>>>> Time: ${this.context.time}`);
}
rewind() {
console.log('>>>> [Play]: rewind');
this.context.time = Date.now();
this.context.changeState(new Stop(this.context));
console.log(`>>>> Time: ${this.context.time}`);
}
}
Every concrete IState implementation needs to describe all the methods. For Play we want to be logging and updating time
for any 'button press', but play one.
We'll need to implement all other states, but they will pretty much follow the same logic in our example, so let's omit them here.
What will it cost?
Potential Cons:
As you can see from the full implementation here using this pattern increases the number of files in the project. Some might consider it a bad thing.
Adding a new function to our player (let's say pause) would require to add some implementation in every concrete IState implementation, which might be tedious. IDEs could help with it though, but again might be considered a con for some developers.
In current implementation concrete states (Play, Stop, etc.) are getting the context reference to work with. This might be considered an issue as we might be exposing too much.
State transitions in our example are explicit. Concrete state knows which state to move to. This might not be ideal as we have tighter coupling and transitions might get hard to track.
Potential Pros:
Context (Player class in our example) class becomes drastically shorter and therefore easier to read and maintain.
Adding a new state (Pause for example) is as easy as adding a new class.
Same goes for removing a state (let's we don't need that Pause anymore).
Changing a particular behaviour for the state means just changing one function (several functions in one class/file).
No more if-else's spread across every function (for me personally this is always a huge selling point).
Conclusion
Splitting code into multiple files is usually a good thing. I can't say that i would personally use this pattern on daily basis, but Pros are definitely overweighting Cons for me.
Hope this was a bit helpful 🙃
Source code if needed
Top comments (0)