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
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
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?
- 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: