
In the previous post of this series, Designing Wordie: Wordie Clone DSA Part I: Data Architecture, we discussed how to generate a set of data structures to model the game Wordie using JavaScript. In this post we are going to discuss how to effectively utilize those data structures in a frontend development environment, namely, React, to make the game run.
The main concern for our data structures is that when we make changes to our data, we need the UI (user interface) to reflect those changes. Hence, before discussing data implementation, we are going to first cover the fundamentals of React component rerendering to have a solid foundation on how React determines when to rerender a component, before we actually start writing any code to make use of our data structures in React.
React utilizes the Object.is comparison algorithm 1, 2 between states in a component to determine whether the component should rerender. If the value for the new state is the same as the value for the previous state, the component will not rerender. The Object.is algorithm is almost the same as the strict equality operator, ===, which basically checks whether two values are the same or whether two variables point toward (reference) the same object, without inspecting the key-value pairs of the object or the elements of the object (for arrays). In short, given a and b, Object.is answers the question, "Are a and b stored in the same location in memory?" If the answer is, "yes", then a is the same as b. Let's run some examples using Object.is to see how it works, and then apply it to React component rerendering:
// Comparing numbers
const a = 2;
const b = 2;
Object.is(a,b) // => true
// Comparing objects #1
const a = { color: "blue" };
const b = { color: "blue" };
Object.is(a,b) // => false
// Comparing objects #2
const a = { color: "blue" };
const b = a;
Object.is(a,b) //=> true
// Comparing objects #3
const a = { color: "blue" };
const b = Object.assign({}, a); // or b = {...a}
Object.is(a,b) // => false
// Comparing arrays #1
const a = [];
const b = [];
Object.is(a,b) // => false
// Comparing arrays #2
const a = [];
const b = a;
Object.is(a,b) // => true
// Comparing arrays #3
const a = [];
const b = [...a]; // or b = a.slice()
Object.is(a,b) // => false
In the first example above we see that if two variables have the same number as value, then the variables are the same. That example might seem awkward, but let's apply it to a React component:
import React, {useState} from 'react';
export default function MyComponent() {
const [count, setCount] = useState(5);
const b = 5;
return (
<>
<button onClick={() => setCount(b)}>
Count is: {count}
</button>
</>
)
}
Will the component rerender when the button is clicked??? No; the initial state value for the component is 5, and the new state value on click is also set to 5; since both state values are the same, the component does not rerender.
Now, let's apply case #1 above for objects comparison, using a similar React component:
import React, {useState} from 'react';
export default function MyComponent() {
const [car, setCar] = useState({color: "blue"});
const newest = {color: "blue"};
return (
<>
<button onClick={() => setCar(newest)}>
The car is {car.color}
</button>
</>
)
}
Will the component rerender when the button is clicked??? Well, before clicking the button, the sentence will read, "The car is blue", and after clicking the button the sentence will read, "The car is blue"; however, since the initial state object in useState, namely, {color: "blue"}, is not the same object referenced by the newest variable, the component will rerender. In fact, the component will rerender after every button click. You might say, "But in the previous example the sentence read 'Count is: 5' , both, before and after clicking the button, and the component was not rerendered." In the first example the state value was the same before and after clicking the button; in this later example not.
In case #2 for objects comparison in the Object.is code snippet, the variable b is assigned to reference the object referenced by the variable a. Hence, a and b reference the same object. That means that if we perform a change to b, that change will also be reflected in a:
const a = { color: "blue" };
const b = a;
b.color = "red";
b // => { color: "red" };
a // => { color: "red" };
// 'a' and 'b' reference the same object
If we want to make a change to b without affecting a, we need to make a clone of a, and make the changes to the clone. We can make a clone of an object using the Object.assign method or the spread operator (case #3 in code snippet):
const a = { color: "blue" };
const b = Object.assign({}, a);
b // => { color: "blue" };
b.color = "red";
b // => { color: "red" };
a // => { color: "blue" };
What does a clone have to do with React component rerender?
If our React component state consists of an object (which includes arrays), and we want the component to rerender with the previous state plus some changes to the state, we need to create a clone of the previous state, modify the clone, and then set the clone as the new state.
Ok, with this knowledge, let us now update the board component for Wordie using React.
The three pieces of data presented in the previous post to start the game were a matrix, a queue, and a pointer. The board was represented as a 6x5 matrix, the queue consisted of 6 index integers, and the pointer was initialized to zero :
let board = [
["", "", "", "", ""],
["", "", "", "", ""],
["", "", "", "", ""],
["", "", "", "", ""],
["", "", "", "", ""],
["", "", "", "", ""],
]
let queue = [0,1,2,3,4,5]
let pointer = 0;
Since there is no going back after processing a word (a row in the board), we'll define the queue outside the component since we will only remove values from the queue, and we'll define the pointer and the board using state variables inside the component, like so:
import React, {useState} from 'react';
const BOARD = new Array(6).fill(0).map(() => new Array(5).fill(""));
let queue = [0,1,2,3,4,5];
export default function MyBoard() {
const [board, setBoard] = useState(BOARD);
const [pointer, setPointer] = useState(0);
return (
<div className="board">
return (
{board.map((row, rowIndex) => {
<div className="row" key={rowIndex}>
{row.map((col, colIndex) => {
return (
<div className="column"
key={parseInt("" + rowIndex + colIndex)}
>
{col}
</div>
);
})}
</div>
);
})}
<div>
);
}
With some styles, the above component renders a 6x5 grid, like the one below:
Now, we can't make changes to the board state variable directly, as those changes will not be reflected in the UI because when React compares the previous board with the changed board, it'll find out that it is the same object, and it will not rerender the component. Also, making direct changes to the board variable would mutate the component state, which runs against React's architecture of immutable state. So, to update the board, we'll have to make a clone of the board, update the clone, and set the clone as the new board. Now, how do we make a clone of a matrix (the board)? If we use the .slice method (board.slice()) or the spread operator ([...board]), the inner arrays of the resulting copy will still reference the inner arrays of the original board as these methods only perform shallow-copies of objects–top level objects are cloned, but objects inside the top level objects are not cloned, but referenced only. Let's prove it:
let board = [
["", "", "", "", ""],
["", "", "", "", ""],
["", "", "", "", ""],
["", "", "", "", ""],
["", "", "", "", ""],
["", "", "", "", ""],
]
let copy = board.slice(); // or copy = [...board]
copy[1][2] = "H";
copy // =>
[
["", "", "", "", ""],
["", "", "H", "", ""],
["", "", "", "", ""],
["", "", "", "", ""],
["", "", "", "", ""],
["", "", "", "", ""]
]
board // =>
[
["", "", "", "", ""],
["", "", "H", "", ""],
["", "", "", "", ""],
["", "", "", "", ""],
["", "", "", "", ""],
["", "", "", "", ""]
]
Changing copy also changed board, hence copy is not a clone of board. It is worth noting that using the .slice method or the spread operator will create a new array, and the component will rerender; however, since the new array is only a shallow-copy, that means we would be mutating the component's state. We can make a clone of the board (also known as a deep copy) by using JavaScript's structuredClone function:
let copy = structuredClone(board);
copy[1][2] = "H";
copy // =>
[
["", "", "", "", ""],
["", "", "H", "", ""],
["", "", "", "", ""],
["", "", "", "", ""],
["", "", "", "", ""],
["", "", "", "", ""]
]
board // =>
[
["", "", "", "", ""],
["", "", "", "", ""],
["", "", "", "", ""],
["", "", "", "", ""],
["", "", "", "", ""],
["", "", "", "", ""]
]
With the clone cleared, let's define a detectKeydown function that will be responsible for handling user inputs and perform changes to the board:
import React, {useState, useRef} from 'react';
const BOARD = new Array(6).fill(0).map(() => new Array(5).fill(""));
const ALPHABET = {A:'A',B:'B',C:'C',D:'D',E:'E'}; // rest
let queue = [0,1,2,3,4,5];
export default function MyBoard() {
const [board, setBoard] = useState(BOARD);
const [pointer, setPointer] = useState(0);
const page = useRef(null);
const detectKeydown = useCallBack(
(e) => {
const row = queue[0];
if(ALPHABET[key]) {
const clone = structuredClone(board);
clone[row][pointer] = e.key;
setBoard(clone);
setPointer(prev => prev + 1);
}
},
[board, pointer]
);
useEffect(() => {
if(!page.current) {
page.current = document;
page.current.addEventListener('keydown', detectKeydown, true);
}
return () =>
page.current.removeEventListener('keydown', detectKeydown, true)
},[detectKeydown]);
return (
// code for rendering board
);
}
In the above code snippet, in the useEffect block, we add a keydown event listener to the HTML document, and bind a detectKeydown function to it. In order to avoid attaching the event listener twice, we store the page in a useRef hook, which has a value of null initially, and gets set on the first component render (component mount). The return value of the useEffect is a lambda (arrow) function that gets called when we exit the component (component unmounts; e.g., leave the page using a navbar link) so that the event listener does not stay active afterwards.
The code in the detectKeydown function updates the board with the user input, and the component will rerender to reflect the updates. And that's how you can use state variables to update the board!!!
Now, notice that for making a single change in the board, we need to clone the whole board first. When designing Wordie Clone, I knew from the beginning that using state variables for the board component would require constantly cloning the board matrix, and so, I developed the board component with a different approach. That will take us to our second method for rerendering the board.
I did not feel happy cloning the whole matrix as that would set a time complexity for rerendering of at least O(mxn), where m is the number of rows, and n the number of columns in the matrix (the board), respectively. So, I took advantage of React's comparison algorithm and the fact that a new object in JavaScript is not the same as a new object, that is,
{} === {} // => false
Object.is({}, {}) // => false
This means that if we use a state variable in a component and initialize it with an empty object, and then later set the variable equal to an empty object, the component will rerender, since the comparison of the previous state value and the current state value returns false. That is, having a component
import React, {useState} from 'react;
export default function MyBoard() {
const [handle, setHandle] = useState({});
return (
// code for rendering board
)
}
and calling
setHandle({});
setHandle({});
setHandle({});
will trigger a component rerender every time we call setHandle({}). So, instead of defining the board variable inside the component, we'll define the board variable outside the component in a stateless variable, and after we're done making changes to the stateless variable, we'll trigger a component rerender using a handle (or switch) state variable:
import React, {useState, useRef} from 'react';
let board = new Array(6).fill(0).map(() => new Array(5).fill(""));
let pointer = 0;
let queue = [0,1,2,3,4,5];
const ALPHABET = {A:'A',B:'B',C:'C',D:'D',E:'E', // rest };
export default function MyBoard() {
const [handle, setHandle] = useState({});
const page = useRef(null);
const detectKeydown = (e) => {
const row = queue[0];
if(ALPHABET[key]) {
board[row][pointer] = e.key;
pointer++;
setHandle({});
}
}
useEffect(() => {
// code for useEffect
},[detectKeydown]);
return (
// code for rendering board
);
}
With the above code snippet we can successfully rerender the component with the user inputs without cloning the board.
The stateful board from method I and this "stateless" board (the board has state, but it is a dummy state, hence "stateless") have similar behavior–on every rerender they both only update the part of the DOM that is changed, as shown below:
now, since the stateless board updates the board in-place, I would expect the stateless board to rerender faster than the stateful board. I carried out some performance tests on the stateful board and the stateless board using the React Profiler component (more on this on a later post) to gather data about the components' rerender time. I designed tests for three board sizes: 6x5, 10x10, and 20x10, yielding a total of 30, 100, and 200 rerenders, respectively. You can run and play with the tests live in the below code sandbox (click on the rectangle-arrow button to open the sandbox in a new window):
This is the data I gathered after running the tests:
From the overall average rerender time, for the 6x5 board, the stateless component rerenders 62.2% faster than the stateful component; for the 10x10 board, the stateless component rerenders 80.8% faster than the stateful component, and for the 20x10 board, the stateless component rerenders 83.5% faster than the stateful component. In short, the stateless board rerenders at least 1.6 times faster than the stateful board. So, as I expected, avoiding cloning the board speeds the component up.
Now, there is a tradeoff here: by using a stateless board, you lose the history of the board, which means you cannot revert the board to a previous state (like the undo button in a text editor); however, for this game, that is fine, as we only move forward, and the only history we need is for the current row in the game, which is handled by the pointer variable.
So let's finish up by summarizing the key points from methods I and II for rerendering the board.
When using a matrix to model Wordie, we can use the structuredClone function to create a deep-copy of the board in a stateful component (there are also other ways to create deep-copies besides structuredClone), whereas if we use stateless variables, we can use a switch state variable for triggering component rerenders after making changes to our stateless variables.
One last note is that using stateless variables is not React's defacto architecture as React runs on immutability, and this has only been an example of a case where a combination of stateless plus state variables can be used to potentially speed things up. Also, the nature of the game (not needing to keep history) allows to more easily make these explorations. Please feel free to add any comments below.
GitHub respository for the code sandbox. You can play with Wordie Clone DSA here, and you can find the code for Wordie Clone DSA here.