How to Implement the State Design Pattern in JavaScript and Integrate It With React Hooks

How to Implement the State Design Pattern in JavaScript and Integrate It With React Hooks


I am writing this article because I haven’t found a solution that looks like mine, so my solution might be useful for someone else.

Table of Content

  • Implementation

  • The full code, so you can copy-paste.

  • Extended state machine (Error state, Copy-Pastable HTML)

  • What problems does it solve?

  • Why this article makes sense.

Implementation

We implement the state design pattern just like the refactoring guru recommends: https://refactoring.guru/design-patterns/state

Implement the Classes

class RoomState {
  #roomClient = null;
  #roomId = null;

  constructor(roomClient, roomId) {
    if (roomClient) {
      this.#roomClient = roomClient;
    }
    if (roomId) {
      this.roomId = roomId;
    }
  }

  set roomClient(roomClient) {
    if (roomClient) {
      this.#roomClient = roomClient;
    }
  }

  get roomClient() {
    return this.#roomClient;
  }

  set roomId(roomId) {
    if (roomId) {
      this.#roomId = roomId;
    }
  }

  get roomId() {
    return this.#roomId;
  }

  join(roomId) {
    throw new Error('Abstract method join(roomId).');
  }

  leave() {
    throw new Error('Abstract method leave().');
  }

  getStatusMessage() {
    throw new Error('Abstract method getStatusMessage().');
  }
}

// -------------------------------------------------------------------------

class PingRoomState extends RoomState {
  join(roomId) {
    this.roomClient.setState(new PongRoomState(this.roomClient, roomId));
  }

  leave() {
    const message = `Left Ping room ${this.roomId}`;
    this.roomClient.setState(new LeftRoomState(this.roomClient, message));
  }

  getStatusMessage() {
    return `In the Ping room ${this.roomId}`;
  }
}

// -------------------------------------------------------------------------

class PongRoomState extends RoomState {
  join(roomId) {
    this.roomClient.setState(new PingRoomState(this.roomClient, roomId));
  }

  leave() {
    const message = `Left Pong room ${this.roomId}`;
    this.roomClient.setState(new LeftRoomState(this.roomClient, message));
  }

  getStatusMessage() {
    return `In the Pong room ${this.roomId}`;
  }
}

// -------------------------------------------------------------------------

class LeftRoomState extends RoomState {
  #previousRoom = null;

  constructor(roomClient, previousRoom) {
    super(roomClient);
    this.#previousRoom = previousRoom;
  }

  join(roomId) {
    this.roomClient.setState(new PingRoomState(this.roomClient, roomId));
  }

  leave() {
    throw new Error(`Can't leave, no room assigned`);
  }

  getStatusMessage() {
    return `Not in any room (previously in ${this.#previousRoom})`;
  }
}

This is our state machine so far

state machine diagramstate machine diagram

Use the State Pattern in the React Hook

The next problem: how do we use the classes in combination with react?

The other articles use useEffect and a string to store the name of the current state; we want to keep our implementation clean.

The roomClient can modify state, if it has a reference to setState function.

Problems:

  • We can’t pass the setState if we initialize the state with the class.
  • We don’t want to return null from the hook.
  • We don’t want to return mock methods that return nothing from the hook.

Solution, provide the roomClient as soon as the state is initialized, right below the useState.

function useRoomClient() {
  const [state, setState] = useState(new PingRoomState());

  // State contains the class
  // Initialize once
  // We can do this thanks to the `set` and `get` methods on
  // `roomClient` property
  if (!state.roomClient) {
    state.roomClient = { setState };
  }

  return state;
}

The Full Code So You Can Copy-Paste

class RoomState {
  #roomClient = null;
  #roomId = null;

  constructor(roomClient, roomId) {
    if (roomClient) {
      this.#roomClient = roomClient;
    }
    if (roomId) {
      this.roomId = roomId;
    }
  }

  set roomClient(roomClient) {
    if (roomClient) {
      this.#roomClient = roomClient;
    }
  }

  get roomClient() {
    return this.#roomClient;
  }

  set roomId(roomId) {
    if (roomId) {
      this.#roomId = roomId;
    }
  }

  get roomId() {
    return this.#roomId;
  }

  join(roomId) {
    throw new Error('Abstract method join(roomId).');
  }

  leave() {
    throw new Error('Abstract method leave().');
  }

  getStatusMessage() {
    throw new Error('Abstract method getStatusMessage().');
  }
}

// -------------------------------------------------------------------------

class PingRoomState extends RoomState {
  join(roomId) {
    this.roomClient.setState(new PongRoomState(this.roomClient, roomId));
  }

  leave() {
    const message = `Left Ping room ${this.roomId}`;
    this.roomClient.setState(new LeftRoomState(this.roomClient, message));
  }

  getStatusMessage() {
    return `In the Ping room ${this.roomId}`;
  }
}

// -------------------------------------------------------------------------

class PongRoomState extends RoomState {
  join(roomId) {
    this.roomClient.setState(new PingRoomState(this.roomClient, roomId));
  }

  leave() {
    const message = `Left Pong room ${this.roomId}`;
    this.roomClient.setState(new LeftRoomState(this.roomClient, message));
  }

  getStatusMessage() {
    return `In the Pong room ${this.roomId}`;
  }
}

// -------------------------------------------------------------------------

class LeftRoomState extends RoomState {
  #previousRoom = null;

  constructor(roomClient, previousRoom) {
    super(roomClient);
    this.#previousRoom = previousRoom;
  }

  join(roomId) {
    this.roomClient.setState(new PingRoomState(this.roomClient, roomId));
  }

  leave() {
    throw new Error(`Can't leave, no room assigned`);
  }

  getStatusMessage() {
    return `Not in any room (previously in ${this.#previousRoom})`;
  }
}

function useRoomClient() {
  const [state, setState] = useState(new PingRoomState());

  // State contains the class
  // Initialize once
  // We can do this thanks to the `set` and `get` methods on
  // `roomClient` property
  if (!state.roomClient) {
    state.roomClient = { setState };
  }

  return state;
}

Extended State Machine (Error State, Copy-Pastable HTML)

We extend the state machine because we want to transition to Error state if we try to leave the room, and it results in an erroneous operation. It allows us to display status messages by calling getStatusMessage.

Diagram

updated state machine diagram with error stateupdated state machine diagram with error state

Code



  
    
    
    Document
  
  
    

    
    
    
  

What Problems Does It Solve?

  • We can scale the state machine without modifying existing code.
  • Less bugs.
  • More understandable code, once we grasp how it works (All we have to do is add a new class for a new state).
  • Avoid complicated if-else blocks, complex state mutations, and one switch statement.
  • It’s nice if you want to create real-time rooms using WebSockets (We can monitor user room connection state and other types of states).

Why This Article Makes Sense

When I searched for state design pattern on Google, these were my first results

Links to the 3 results:

Searching react state design pattern gives implementations that look nothing like the implementation on https://refactoring.guru/design-patterns/state

Links to the results from the search:



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *