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.
Implementation
Implement the classes
Use the state pattern in the react hook
The full code, so you can copy-paste.
Extended state machine (Error state, Copy-Pastable HTML)
Diagram
Code
What problems does it solve?
Why this article makes sense.
We implement the state design pattern just like the refactoring guru recommends: https://refactoring.guru/design-patterns/state
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
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:
setState
if we initialize the state with the class.
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;
}
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;
}
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
.
<!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/react@18.3.1/umd/react.development.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@18.3.1/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>
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: