DEV Community

Cover image for State machines with TypeScript
Steffen Lips
Steffen Lips

Posted on

State machines with TypeScript

The problem

Who does not know this: You write a simple component e.g. a search field to search for stored entries. Whenever the input changes the query should be sent to the database. The results should be displayed in a dropdown. So far, so good.

That is when you remember that the connection to the database can't be established yet. So you introduce a boolean variable dbConnected.

Next, you want the search to be sent only if no further input is made within two seconds. So the next variable userIsTyping is introduced.

Then, as long as the search has not returned a result, a spinning wheel should be displayed (isSearching). And it goes on and on in this way and you lose quite fast the overview of your *status*variables. Certain combinations are even invalid, like dbConnected == false and isSearching == true. And just these invalid states cause later headaches, because somehow the component manages to get into this invalid state from which it can't get out anymore.

The solution

State machines are especially useful to monitor the state of a component. And basically your component is nothing else than a state machine. Let's first list all the states the component can take:

const enum States {
  UNINITIALIZED,
  CONNECTING,
  CONNECTED,
  INITIALIZED,
  TYPING,
  SEARCHING,
  SHOW_RESULTS,
}
Enter fullscreen mode Exit fullscreen mode
  • UNINITIALIZED: Initial state
  • CONNECTING: Connecting the database
  • INITIALIZED: Connection is estabished und the component is ready to use
  • TYPING: The edit field is in use
  • SEARCHING: The input has finished and is sent to the database
  • SHOW_RESULTS: The results are received and presented

Your component is always in exactly one of these states. And there are defined transitions from one state to another. For example, TYPING can never come before INITIALIZED. If your component changes from one state to another, certain actions shall be executed. For example, the input field should be enabled as soon as the component is ready. So the basic structure of your component could look like this:

class SearchFieldController {
  private _state: States = States.UNINITIALIZED;

  private _inputElement: HTMLInputElement | undefined;
  private _resultElement: HTMLDivElement | undefined;
  private _dbConnection: any;

  public register() {
    this._inputElement = document.querySelector(".search") as HTMLInputElement;
    this._resultElement = document.querySelector(".result") as HTMLDivElement;
    if (this._inputElement && this._resultElement) {
      this._state = States.CONNECTING;
      this._inputElement.addEventListener("change", () => {
        this._state = States.TYPING;
      });
    }
  }

  protected onEnterConnecting(prev: States) {
    // connect database
    this._dbConnection.connect((connected: boolean) => {
      if (connected) this._state = States.INITIALIZED;
      else this._state = States.UNINITIALIZED;
    });
  }

  protected onEnterInitialized(prev: States) {
    // enable search field
  }

  protected onEnterTyping(prev: States) {
    // set a timer to recognize that there is no further input
  }

  protected onEnterSearching(prev: States) {
    // show spinning wheel
  }

  protected onLeaveSearching(next: States) {
    // hide spinning wheel
  }

  protected onEnterShowResults(prev: States) {
    // show results
  }

  protected onLeaveShowResults(next: States) {
    // hide results
  }
}
Enter fullscreen mode Exit fullscreen mode

It already looks like a status machine, doesn't it? Wouldn't it be great if the state transition methods were called exactly when the state variable changes? And that's exactly what we do now, using Typescript decorators.

Definition of the states and the transitions

First, we declare the variable state as the one to be monitored. In the declaration, we specify all the values that the state variable can take. The first six transitions are the transitions that result from the sequence. The last three additional transitions allow the user to change or delete the input during the search or the result display.

    @State([
        States.UNINITIALIZED,
        States.CONNECTING,
        States.INITIALIZED,
        States.TYPING,
        States.SEARCHING,
        States.SHOW_RESULTS
    ])
    @Transition(States.UNINITIALIZED, States.CONNECTING)
    @Transition(States.CONNECTING, States.CONNECTING)
    @Transition(States.UNINITIALIZED, States.INITIALIZED)
    @Transition(States.INITIALIZED, States.TYPING)
    @Transition(States.TYPING, States.SEARCHING)
    @Transition(States.SEARCHING, States.SHOW_RESULTS)
    @Transition(States.SHOW_RESULTS, States.INITIALIZED)
    @Transition(States.SHOW_RESULTS, States.TYPING)
    @Transition(States.SEARCHING, States.TYPING)
    private _state: States = States.UNINITIALIZED;
Enter fullscreen mode Exit fullscreen mode

The callbacks of the transitions

For each transition the appropriate callback should be called.


    @EnterState(States.SEARCHING)
    protected onEnterSearching(prev: States) {
        // show spinning wheel
    }

    @LeaveState(States.SEARCHING)
    protected onLeaveSearching(next: States) {
        // hide spinning wheel
    }

Enter fullscreen mode Exit fullscreen mode

The implementation

Now we only have to implement the four functions. The logic takes place in the function State all others are used to manage the state callbacks and the transitions.
The decorated property is overwritten with a new getter and setter. If now the value of the property is changed, first it is checked whether the desired status change is possible. If so, then first the Leave callback of the current status is called. Then the new status is set and finally the corresponding Enter callback is called.

class StateObject {
  allowedStates: number[] = [];
  allowedTransitions: { [key: number]: number[] } | undefined;
  enterFunctions: { [key: number]: (prev: number) => void } = {};
  leaveFunctions: { [key: number]: (next: number) => void } = {};
}

export interface StateMachine {
  __stateObject: StateObject | undefined;
  [key: string]: any;
}

export function State(states: number[]) {
  return (target: Object, propertyKey: string) => {
    let stateMachine = target as StateMachine;
    if (!stateMachine.__stateObject)
      stateMachine.__stateObject = new StateObject();
    stateMachine.__stateObject.allowedStates = states;

    Object.defineProperty(target, propertyKey, {
      get: function (this: any) {
        return this.__stateValue;
      },
      set: function (this: any, newValue: number) {
        if (!stateMachine.__stateObject) return;
        let oldValue = this.__stateValue;

        if (newValue == oldValue) return;

        if (stateMachine.__stateObject.allowedStates.indexOf(newValue) < 0) {
          console.log("unallowed value");
          return;
        }

        if (
          oldValue != null &&
          stateMachine.__stateObject.allowedTransitions &&
          stateMachine.__stateObject.allowedTransitions[oldValue] &&
          stateMachine.__stateObject.allowedTransitions[oldValue].indexOf(
            newValue
          ) < 0
        ) {
          console.log("unallowed transition");
          return;
        }
        if (
          oldValue != null &&
          stateMachine.__stateObject.leaveFunctions[oldValue]
        )
          stateMachine.__stateObject.leaveFunctions[oldValue](newValue);
        this.__stateValue = newValue;
        if (
          oldValue != null &&
          stateMachine.__stateObject.enterFunctions[newValue]
        )
          stateMachine.__stateObject.enterFunctions[newValue](oldValue);
      },
    });
  };
}

export function Transition(from: number, to: number) {
  return (target: Object, propertyKey: string) => {
    let stateMachine = target as StateMachine;
    if (!stateMachine.__stateObject)
      stateMachine.__stateObject = new StateObject();
    let stateObject = stateMachine.__stateObject;
    if (!stateObject.allowedTransitions) stateObject.allowedTransitions = {};
    if (stateObject.allowedTransitions[from] == null) {
      stateObject.allowedTransitions[from] = [];
    }
    stateObject.allowedTransitions[from].push(to);
  };
}

export function EnterState(state: number) {
  return (
    target: Object,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) => {
    let stateMachine = target as StateMachine;
    if (!stateMachine.__stateObject)
      stateMachine.__stateObject = new StateObject();
    let stateObject = stateMachine.__stateObject;
    stateObject.enterFunctions[state] = stateMachine[propertyKey] as (
      prev: number
    ) => void;
  };
}

export function LeaveState(state: number) {
  return (
    target: Object,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) => {
    let stateMachine = target as StateMachine;
    if (!stateMachine.__stateObject)
      stateMachine.__stateObject = new StateObject();
    let stateObject = stateMachine.__stateObject;
    stateObject.leaveFunctions[state] = stateMachine[propertyKey] as (
      next: number
    ) => void;
  };
}
Enter fullscreen mode Exit fullscreen mode

Summary

We have now created an implementation of a generic state machine in less than 100 lines. Why not use an existing implementation? That would have solved the problem too, wouldn't it?
Of course! But the approach using decorators is very charming. The decorators can be used in any class and keep it free from implementation details. The code becomes more readable and structured.
In fact, I have already used this implementation in many projects. There were complex controllers used for mouse and touch control in 3D scenes for instance.

This was my very first article here at dev.to. I hope I can inspire some of you with this approach. It would make me happy.
I like the decorators in TypeScript and I have implemented some more "little helpers". So, if you are interested in, let me know?

Cheers

Top comments (0)