I am currently learning React and tried building the UI of a basic minesweeper game as a first project. My goal was to write simple, re-usable components. I will go over each of them and do my best to explain the rationale behind them and, after that, explain how they are all tied together in the main App
component. Please let me know if anything I have written can be done in a more efficient or idiomatic way.
As said before I have built the UI using React, the game logic is separate from the the React code. The whole project can be seen on github and the game can be actually played on my neocities website. One last precision: for readability reasons, the following is jsx code.
The Picker component
The picker component is used to create a group of labeled radio-buttons. It comes with a function checkedRadio(groupName)
which returns the html value
attribute of the chosen option. Note that I render a collection of elements without using key
, here's why:
- each radio-button has an
id
attribute to make it work with the htmllabel
, I feel like adding a uniquekey
would be redundant - the list of radio-button shouldn't change over time, which means no re-render, and thus no need for a
key
/* Picker component:
* Return a group of radio buttons.
* Expected props:
* - name: the html name for the group of radio buttons
* - descriptions: a sequence of strings that each describes an option in the
* group
* - defaultPick: the index of the description for the default option picked
* - onChange: a function called when the radio button checked changes, this
* function can take as an argument an object of the form
* { description, indexOfDescription }
* - radioClass: the class name for every option of the Picker
* Additional props will be attached to the wrapping div.
*/
const Picker = function({name, descriptions, defaultPick, onChange, radioClass, ...rest}) {
return (
<div {...rest}>
{descriptions.map((description, i) => {
return (
<span className={radioClass}>
<input type='radio'
name={name}
value={description}
id={`${ name }-${ description }`}
defaultChecked={i===defaultPick}
onChange={() => onChange({description, i})} />
<label htmlFor={`${ name }-${ description }`}>{description}</label>
</span>);
})}
</div>
);
}
/* Return the value of the radio button checked in the group of radio buttons
* that is named groupName.
* Will throw an error if no such group is found.
*/
const checkedRadio = function(groupName) {
const grp = document.querySelectorAll(`input[name=${groupName}`);
for (const node of grp)
if (node.checked)
return node.value;
throw new Error(`no radio group of name ${ groupName }`);
}
The Matrix component
This component is used to render a rectangular 2D array of data. It uses a Row
component to render lines of cells. How each cell is render is up to the user, and a cellComponent
should be provided for both of these components to work as intended. Here as well, I am not using any key
, it is up to the user to give a key
(or not) to their cells. Given that the user can make their cells have key
s, I figured that Row
elements don't need any key
of their own.
/* Matrix component:
* Return a table-like element.
* Expected props:
* - array: the 2D array that must be translated into a pseudo-table
* - rowClass: the class name for every row in the Matrix
* - onClick, onContextMenu: functions to be called when a cell of the
* Matrix is respectively left-clicked or right-clicked
* - cellComponent: a component to be instanciated for every cell of the
* given 2D array. Such a component should accept props of the form
* { cell, x, y, onClick, onContextMenu }
* Additional props will be attached to the wrapping div.
*/
const Matrix = function({array, rowClass, onClick, onContextMenu, cellComponent, ...rest}) {
return (
<div {...rest}>
{array.map((row, y) => {
return (
<Row
className={rowClass}
row={row}
y={y}
onClick={onClick}
onContextMenu={onContextMenu}
cellComponent={cellComponent} />
);
})}
</div>
);
}
/* Row component:
* Return a flat sequence of cells. This component is meant to be instanciated
* by the Matrix component.
* Expected props:
* - row: the flat (1D) array that must be translated into a Row
* - y: the number of the Row in the Matrix
* - onClick, onContextMenu: functions to be called when a cell of the
* Row is respectively left-clicked or right-clicked
* - cellComponent: a component to be instanciated for every cell of the
* given 2D array. Such a component should accept props of the form
* { cell, x, y, onClick, onContextMenu }
* Additional props will be attached to the wrapping div.
*/
const Row = function({row, y, onClick, onContextMenu, cellComponent, ...rest}) {
return (
<div {...rest}>
{row.map((cell, x) => cellComponent({x, y, onClick, onContextMenu, cell}))}
</div>
);
}
My own cellComponent
: the mineCell component
This is the cellComponent
that is meant to be called by the Matrix
component. It is meant to represent cells in a minesweeper game, and therefore relies more on the logic part (which isn't covered here). mines.UNREV
, mines.BOMB
and mines.FLAG
are constants defined in the logic part that are used to differentiate between kinds of cell, here they are used to apply different styles to different cells. Still in the logic part, each cell can be represented be a single character, which is the actual cell
argument passed to the mineCell
component.
// Map to determine the CSS class of a cell based on its value
const cellClasses = new Map(
[[mines.UNREV, 'tile-unrevealed'],
[mines.BOMB, 'tile-bomb'],
[mines.FLAG, 'tile-flag']]
);
// Cell component for the Minesweeper game
const mineCell = function({x, y, onClick, onContextMenu, cell}) {
let className = cellClasses.get(cell);
return (
<span
className={className}
onClick={() => onClick(x, y)}
onContextMenu={(evt) => { evt.preventDefault(); onContextMenu(x, y) }}>
{cell}
</span>
);
}
Where everything is tied together: the App component
Let's have a look at the render
function. It uses a Picker
element (with difficulties defined in the logic part as descriptions
) to let the user choose the difficulty setting and a Matrix
element to render all the cells of the game.
// labels for the difficulty settings
const descriptions = Array.from(mines.difficulties.keys());
// Map to determine the reset button css class depending on the minefield state
const resetClasses = new Map(
[[mines.WON, 'reset-won'],
[mines.LOST, 'reset-lost'],
[mines.PLAYING, 'reset-normal']]
);
App.prototype.render = function() {
const minefield = this.state.minefield;
let resetButtonClass = resetClasses.get(minefield.state);
return (
<div id='app'>
<Picker
id='picker'
descriptions={descriptions}
name='difficulty'
defaultPick={1}
onChange={() => this.changeDifficulty()}
radioClass='radio-button'/>
<button
id='reset-button'
className={resetButtonClass}
onClick={() => this.resetGame()}
alt='button to reset the game'>
</button>
<Matrix
id='game'
array={minefield.view}
rowClass='mines-row'
onClick={(x, y) => this.mineviewLeftClick(x, y)}
onContextMenu={(x, y) => this.mineviewRightClick(x, y)}
cellComponent={mineCell}/>
</div>
);
}
Here's how the App
component is defined. The chosen difficulty can be retrieved at any moment by finding which option is checked in the difficulty Picker
. The state
only contains the Minefield
instance. (Quick precision about the Minefield
constructor defined in the logic part: it requires a position to instantiate any object so that the first cell clicked is not a bomb. For that reason anything displayed before the first click of a game is actually a dummy minefield.)
// Dummy minefield used when the game hasn't started yet
const DUMMY = 'du';
const dummyMinefield = function([width, height, _]) {
return {
view: Array(height).fill(Array(width).fill(mines.UNREV)),
state: DUMMY
};
}
const App = function(props) {
React.Component.call(this, props);
Object.defineProperty(this, 'difficulty', {
enumerable: true,
get: function() { return checkedRadio('difficulty'); }
});
this.state = {
minefield: dummyMinefield(mines.difficulties.get(descriptions[1])),
};
}
App.prototype = Object.create(React.Component.prototype);
App.prototype.constructor = App;
The rest is the different functions: changing difficulty, starting the game over, clicking on a cell, putting a flag on a cell.
// Change the difficulty picked
App.prototype.changeDifficulty = function() {
this.setState({
minefield: dummyMinefield(mines.difficulties.get(this.difficulty)),
});
}
// Reset the on-going game of minesweeper
App.prototype.resetGame = function() {
this.setState({
minefield: dummyMinefield(mines.difficulties.get(this.difficulty)),
});
}
// Handle click events on the Matrix component that represents the minefield
/* Handle left click events
* Reveal the minefield's cell at coordinates x, y if the minefield is not a dummy.
* Create a minefield otherwise
*/
App.prototype.mineviewLeftClick = function(x, y) {
if (this.state.minefield.state===DUMMY) {
const [width, height, bombs] = mines.difficulties.get(this.difficulty);
const minefield = new mines.Minefield(width, height, [x, y], bombs);
this.setState({minefield});
}
else {
const minefield = this.state.minefield.reveal([x, y]);
this.setState({minefield});
}
}
/* Handle right click events
* Flag the minefield's cell at coordinates x, y if the minefield is not a dummy.
*/
App.prototype.mineviewRightClick = function(x, y) {
if (this.state.minefield.state!==DUMMY) {
const minefield = this.state.minefield.flag([x, y]);
this.setState({minefield});
}
}
That's it, I hope it's not to long or too vague and I am looking forward to hear advice on how to write more idiomatic React code.