DEV Community

Cover image for πŸ“— Object Behavioural: State
Jokerwolf
Jokerwolf

Posted on • Edited on

πŸ“— Object Behavioural: State

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.

Here is a formal diagram:
UML diagram of State pattern

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 {}
}
Enter fullscreen mode Exit fullscreen mode

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 {}
}
Enter fullscreen mode Exit fullscreen mode

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 {}
}
Enter fullscreen mode Exit fullscreen mode

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}`);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

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)