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

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

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:

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

Code

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div id="root"></div>

    <script src="https://cdn.jsdelivr.net/npm/[email protected]/umd/react.development.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/umd/react-dom.development.js"></script>
    <script>
      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() {
          // Extend to shift to error state
          this.roomClient.setState(
            new ErrorRoomState(
              this.roomClient,
              new Error(`Can't leave, no room assigned`),
            ),
          );
        }

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

      // Extend our state machine to hold one more state.
      class ErrorRoomState extends RoomState {
        #error = null;

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

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

        leave() {
          // Do nothing... We can't move anywhere. We handled error.
        }

        getStatusMessage() {
          return `An error occurred. ${this.#error.message}`;
        }
      }

      const { useState } = React;

      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;
      }

      // ----------------------------------------------------------------------
      // Usage example
      // ----------------------------------------------------------------------

      const e = React.createElement;

      function useWithError(obj) {}

      function App() {
        const roomClient = useRoomClient();

        return e(
          'div',
          null,
          e('h1', null, 'Change room state'),
          e('p', null, `Status message: ${roomClient.getStatusMessage()}`),
          e(
            'div',
            null,
            e('button', { onClick: () => roomClient.join('a') }, 'Join'),
            e('button', { onClick: () => roomClient.leave() }, 'Leave'),
          ),
        );
      }

      const { createRoot } = ReactDOM;
      const root = document.getElementById('root');
      createRoot(root).render(React.createElement(App));
    </script>
  </body>
</html>

What Problems Does It Solve?

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: