Why does it make sense to use FlowType or TypeScript when working with JavaScript? To give an appropriate answer, the best idea would be to build a small game or application to make the benefits clear.
This is part 2 of "Flow with React". In part 1 we were able to build a basic board that renders 3 rows containing 3 cells each. Now it's time to build upon what we have, and start structuring our TicTacToe game and add interactivity.
It's time to refactor our single component and create a Board
and Cell
component.
The refactoring is nothing special or noteworthy, but for the sake of completeness here's how our components are structured now.
const Cell = ({ cell: CellType }) => {
return <div style={{
float: 'left',
textAlign: 'center',
border: '1px solid #eee',
padding: '75px'
}}>
cell
</div>
}
const Board = ({ board: BoardType }) : React$Element<any> => {
return <div>
{board.map((row, i) => {
return <div style={{width: '600px', height: '150px'}} key={i}>
{row.map((cell, j) => <Cell key={j} cell={cell} /> )}
</div>
})}
</div>
}
class TicTacToe extends React.Component<*, State> {
state = {
board: board,
status: {type: 'Running'},
player: 0,
}
render() {
const {board} = this.state
return <div>{
<Board board={board} />
}</div>
}
}
So we have broken our app into multiple components and did a little renamng to avoid some name clashing.
We renamed Board
type to simply BoardType
and the Cell
to CellType
. Now that we have manually restructured our game,
it's time to move on to the more interesting tasks. We're still rendering 'cell' to the screen. But what we actually want to do, is render the correct representation, i.e. a circle or a cross.
Because we know about the type that will be passed in, we can display appropriate visual representation.
Let's write a small function that recieves the Cell
and returns a string.
const displayCell = (cell: CellType) : string => {
switch(cell.type) {
case ' Circle': return 'O'
case 'Cross': return 'X'
default: return ''
}
}
We can quickly test our displayCell
function to verify it works as expected.
console.log('X' === displayCell({type: 'Cross'}))
And for clarity, this is how our Cell
component looks like now:
type CellProps = {
cell: CellType,
}
const Cell = ({cell} : CellProps) => {
return <div style={{
float: 'left',
textAlign: 'center',
border: '1px solid #eee',
padding: '75px'
}}>
{displayCell(cell)}
</div>
}
Our next task is to add interactivity, otherwise the game is unusable. Let's also recap what we actually need to do:
- User can click on a cell, if the cell is empty we either render a circle or a cross.
- Everytime a cell is updated, users switch.
- If a row or a column or a diagonal has the same type (cirlce or cross), there is a winner and the game ends.
- If all cells are filled and there is no winner up to this point, then the game is a tie.
What we can see is that there are a number of possible combinations we need to keep track of.
To get things going, we'll focus on the player switching part.
const switchPlayer = (player: Player) : Player => {
switch(player) {
case 0: return 1
case 1: return 0
default: return 0
}
}
We pass in a Player
and we return a Player
.
Continuing, we will need to implement an update function that will update a cell.
So how can tackle this in a sane manner?
We have a Board
type, which is modelled as 3x3 cells, that means if we wanted to update
the top left cell, we could access it via board[0][0]
and the right bottom cell via board[3][3]
. Another uproach is
transform between the 3x3 board and a flat list.
// Helper functions
const toFlatList : (list: BoardType) => Array<CellType> = list =>
list.reduce((xs, x) => {
return xs.concat(x)
}, [])
const toBaord : (Array<CellType>) => BoardType = ([c1, c2, c3, c4, c5, c6, c7, c8, c9]) => {
return [
[c1, c2, c3],
[c4, c5, c6],
[c7, c8, c9]
]
}
We will leverage these two functions and transform the data forth and back when needed. For example we can now update a cell by just knowing about the index. This will simplify things significantly. Ofcourse we could also take the other route, mainly being having to define a column type, and switching over the row and then the column, but this can come with some significant overhead. Our current implementation should be suitable. Let's implement a function that updates a cell.
const updateCell = (board: BoardType, player: Player, index: number) : BoardType => {
const cells = toFlatList(board)
const cell = cells[index]
if (cell && cell.type === 'Empty') {
const updatedCell : Circle | Cross = player === 0 ? {type: 'Cross'} : {type: 'Circle'}
return toBaord([...cells.slice(0, index), updatedCell, ...cells.slice(index + 1)])
}
return board
}
We convert the passed in board to a flat list and access the passed in index.
If the Cell
type is `Èmpty' we update the cell with the right type depending on the
defined player. There is no magic involved here. Only a function that always returns a board, and updates
the board if an update is possible. Further more we can easily test this function, but will leave this as a task to the interested reader.
Our next step is to create a function that is triggered when the player clicks on the cell. Also, we should keep in mind that if a player clicks a filled cell, nothing should happen.
const isCell = (board: BoardType, index: number) : boolean => {
const list = toFlatList(board)
return list[index] !== undefined
}
isCell
checks if the actual cell exists on the board, which will get called
when wanting to update the actual state. Only when valid, will we a actually call setState with the
updated board and player. Adding a setCell
method to our TicTacToe class and passing this method down
to the actual cell should be enough to display the correct cell state.
class TicTacToe extends React.Component<*, State> {
...
setCell = (index: number) : void => {
this.setState(state => {
const {board, player} = state
return isCell(board, index)
? {
player: player === 0 ? 1 : 0,
board: updateCell(board, player, index),
}
: {}
})
}
render() {
const {board} = this.state
return <div>{
<Board board={board} updateCell={this.setCell} />
}</div>
}
}
Now we all need to do, is pass the newly defined method via the Board component to the Cell.
One important aspect to note is that we're calculating the cell index on the fly here onClick={() => updateCell(i*3 + j)
.
As earlier mentioned, we could also change the implementation and define column types as well, accessing
the cells via board[0][0]
i.e. If you have time and interest and try to implement in this way.
type BoardProps = {
board: BoardType,
updateCell: (i: number) => void
}
const Board = ({board, updateCell} : BoardProps) : React$Element<any> => {
return <div>
{board.map((row, i) => {
return <div style={{width: '600px', height: '150px'}} key={i}>
{row.map((cell: CellType, j) =>
<Cell key={j} cell={cell} onClick={() => updateCell(i*3 + j)}/>
)}
</div>
})}
</div>
}
Finally, our Cell component calls this function via onClick. Here is our updated Cell component, including some minor style changes.
type CellProps = {
cell: CellType,
onClick: () => void,
}
const Cell = ({cell, onClick} : CellProps) => {
return <div
style={{
float: 'left',
textAlign: 'center',
fontSize: '3em',
border: '1px solid #eee',
height: '150px',
width: '150px',
textAlign: 'center',
verticalAlign: '50%',
lineHeight: '150px',
}}
onClick={onClick}
>
{displayCell(cell)}
</div>
}
Clicking on a cell will update the cell incase it's empty.
We're getting closer to finalizing this game. What is left to do?
Up untill now, we don't know if the game has ended and if there is an actual winner.
Checking if the game is over, can be achieved by checking if there is an Empty
cell left.
type IsFinished = (board: BoardType) => boolean
const isFinished : IsFinished = board =>
toFlatList(board).reduce((xs, x) => xs && x.type !== 'Empty', true)
All we need to do is reduce over the flatted list and check if there is an empty cell left.
Continuing to the validation part: we want to know if a row, or a column or a diagonal contain the same type, eihter being a cross or a cirlce.
Because we choose to convert between the 3 x 3 board and flat list, we can convert any combination of indexes to a Row
.
Let's define the possible combinations we need to check for:
const row1 = [0, 1, 2]
const row2 = [3, 4, 5]
const row3 = [6, 7, 8]
const col1 = [0, 3, 6]
const col2 = [1, 4, 7]
const col3 = [2, 5, 8]
const diag1 = [0, 4, 8]
const diag2 = [2, 4, 6]
const rows = [row1, row2, row3, col1, col2, col3, diag1, diag2]
So our rows
contains all possibele combinations. Everything converted into row, no matter if it's an actual row or not.
Next, we need a function that can pick any values from a given list and return a Row
.
const pick = (selection: Array<number>, board: BoardType) : Maybe<Row> => {
const flatlist : Array<CellType> = toFlatList(board)
const result = selection.reduce((xs, x) => {
const cell : ?CellType = flatlist[x]
return cell ? [...xs, cell] : xs
}, [])
const [c1, c2, c3] = result
if (c3.length === 3) {
return {type: 'Just', result: [c1, c2, c3]}
}
return {type: 'Nothing'}
}
Our pick function will take care of returning a Row. Once we have a row we can validate the cells by checking if the share the same type.
const validateRow = (row: Maybe<Row>) : Player | null => {
if (row.type === 'Nothing') return null
const [one, two, three] = row.result
if ((one.type === two.type) && (one.type === three.type)) {
return one.type === 'Cross' ? 0 : one.type === 'Circle' ? 1 : null
}
return null
}
There is not really too much to say about our validateRow
function, except that we return a player or null as a result.
Which means that we can now which player won, by checking if the same type is a cross or a cirlce and mapping it back
to the player.
To wrap this all up we need to connect our validateRow function with the previously defined possible row combinations.
We can write an isWinner
function that accepts the board and runs all the possible combinations against the validateRow
function.
As soon as we have a validRow, we also have a winner. Technically reducing over the row combinations should suffice.
By simply returning a player and winning row tuple, we can later display this information on the screen.
type IsWinner = (board: BoardType) => [Player, Row] | false
const isWinner: IsWinner = (board, player) => {
const row1 = [0, 1, 2]
const row2 = [3, 4, 5]
const row3 = [6, 7, 8]
const col1 = [0, 3, 6]
const col2 = [1, 4, 7]
const col3 = [2, 5, 8]
const diag1 = [0, 4, 8]
const diag2 = [2, 4, 6]
const rows : Array<Array<number>> = [row1, row2, row3, col1, col2, col3, diag1, diag2]
return rows.reduce((selected, selection) => {
if (selected) return selected
const row : Maybe<Row> = pick(selection, board)
if (row.type === 'Nothing') return selected
const winner = validate(row)
if (winner !== null) {
return [winner, row.result]
}
return false
}, false)
}
Finally, we will also need to call the ìsFinishedand
isWinnerfunctions at the appropriate place. Let's update our previously defined
setCell` method.
setCell = (index: number) : void => {
this.setState(state => {
const {board, player} = state
if (!isCell(board, index)) return {}
const updatedBoard = updateCell(board, player, index)
const winner = isWinner(updatedBoard)
if (winner) {
return {
board: updatedBoard,
status: {type: 'Just', result: winner},
}
} else if (isFinished(updatedBoard)) {
return {
board: updatedBoard,
status: {type: 'Nothing'}
}
} else {
return {
board: updatedBoard,
player: switchPlayer(player),
}
}
})
}
There is alot going on here. We go through several steps: first we check if the move is valid.
If it is valid, we then check if we have a winner, and if not, we check if the game has a winner.
You might refacor this, or move the isWinner
and isFinished
checks to the ComponentDidUpdate
method.
Feel free to experimen.
We have an actual TicTacToe game now. There are still some more refinements needed, but out of scope of this write up. If you're interested in finalizing the game, here are some ideas:
- prevent any clicks after the game has ended or in case there is a winner.
- Display the current player.
- Display the game status.
- Highlight the winning combination.
If you have any further questions or insights please provide feedback via Twitter