minesweeper-react-ui
Date Published
# Building Minesweeper in React: A STAR Case Study in Component Architecture
I built Minesweeper in React and TypeScript as a deliberate exercise in architecture. The game itself is simple enough to finish in an afternoon, which is exactly what makes it useful: it gives you room to think about structure rather than just getting something on screen. This post walks through the build using the STAR frame — the situation, the task, the architectural decisions, and the result.
---
## Situation
Minesweeper looks trivial until you try to hold the whole thing in your head.
The domain requires a two-dimensional board of hidden cells, some of which contain mines. Left-clicking a cell reveals it; if it's a mine, the game ends; if it's safe, it shows the count of adjacent mines; if it has no adjacent mines, the reveal cascades outward until it hits numbered cells. Right-clicking flags a cell. The board is configured by the player. And the win condition depends on the relationship between revealed, flagged, and mined cells.
That surface area forces you to deal with things that simple demos usually skip:
- two-dimensional indexing and boundary conditions
- recursive traversal that must terminate
- the distinction between stored state and derived values
- two distinct input gestures on the same element
- React's rendering model and effect lifecycle
A naive build will play perfectly on the default input and fall apart the moment someone changes the board dimensions. I wanted to avoid that by getting the structure right first.
---
## Task
The goal was a playable, self-contained Minesweeper with a clean internal architecture:
- Accept a board specification from the user in the form `width,height,mines` — e.g. `3,3,1`
- Generate a board of that size with that many mines
- Compute adjacency counts for every non-mined cell
- Reveal on left-click, flag on right-click
- Cascade reveals through empty regions
- Detect and announce both win and loss
- Display the grid dimensions and remaining mine count
Beyond the feature list, the objective was to demonstrate that even a small application deserves a considered decomposition — and that the patterns we reach for in large systems apply just as well at this scale.
---
## Action
### Component decomposition
I broke the application into five components, each with a single responsibility:
- **`Minesweeper`** — the container. Owns application state and renders the shell.
- **`GameSeedInput`** — parses the user's board specification and emits a start signal.
- **`Grid`** — the engine. Holds the game logic and renders the board.
- **`GridSquare`** — a presentational component. Renders one cell.
- **`GameOver`** — an overlay, conditionally rendered.
This follows the **Single Responsibility Principle** at the component level. The engine is where the rules live. The square is where a cell is drawn. The container is where state is held. Each piece has exactly one reason to change, so a change to how a cell is styled never touches the game logic, and a change to the rules never touches the presentation.
### State architecture: a single source of truth
Rather than scattering state across components, everything lives in `Minesweeper`:
```js
const INITIAL_STATE = {
gameOver: false,
gameState: "Please enter {width of grid, height, number of mines}, e.g. {3,3,1}:",
gameWon: false,
gridData: [],
height: 0,
minesCount: 0,
revealed: [],
width: 0
};
```
This is the **lifted state** pattern. Children don't own game state; they receive it as props and communicate changes upward. There is exactly one place to look to understand what the application knows at any moment.
The update mechanism is a partial-merge callback:
```js
const onStateChange = (newState: any) => {
setState(prevState => ({ ...prevState, ...newState }));
}
```
This is a small API with a large effect. Callers don't need to construct a complete state object — they send only what changed, and it merges into what's already there. It uses the functional form of `setState`, so it always reads the freshest state and sidesteps stale-closure bugs. For an application of this size, it's a clean and expressive idiom that keeps the update sites terse.
### Unidirectional data flow
State flows down as props; events flow up as callbacks. `Minesweeper` passes `state` and `onStateChange` to `Grid`; `Grid` passes `value` and handlers to `GridSquare`. There are no side channels, no context, no external store.
This matters because it makes the data flow traceable by reading the component tree. If something isn't updating, there's one path to follow. If something is updating too often, there's one path to inspect.
### Separation of concerns: logic versus presentation
`GridSquare` is deliberately dumb. It receives a cell object and renders it. Its entire logic is two small functions:
```js
const getClassName = (): string => {
return "grid-square" +
(value.isRevealed ? "" : " is-hidden") +
(value.isMine ? " is-mine" : "") +
(value.isFlagged ? " is-flag" : "");
}
const getValue = () => {
if (!value.isRevealed) return props.value.isFlagged ? "🚩" : null;
if (value.isMine) return "💣";
if (value.neighbour === 0) return null;
return value.neighbour;
};
```
Two decisions here are worth calling out.
First, the **class composition** pattern: building the class string from the cell's state rather than branching inside JSX. This keeps the render tree flat and the styling declarative — the CSS class *is* the state.
Second, **view logic is separated from game logic**. The component asks "is this revealed?" and "is this flagged?" It doesn't know or care how those became true. That's the engine's business. This is separation of concerns in its most practical form: a presentational component should be answerable to its props alone.
### Domain modelling
I modelled the board as a two-dimensional array of cell objects rather than parallel arrays or a flat array with arithmetic:
```js
{
x: i,
y: j,
isMine: false,
neighbour: 0,
isRevealed: false,
isEmpty: false,
isFlagged: false
}
```
Each cell carries its own coordinates. That means the traversal and recursion functions don't need to thread index arithmetic through their signatures — they pass cells around and read `value.x` and `value.y`. It's a small decision with a large readability payoff, and it keeps the recursive functions focused on the rule rather than the coordinate bookkeeping.
### The generation pipeline
Board construction is expressed as a three-stage pipeline:
```js
const _initGridData = (height, width, minesCount): any[] => {
let data = createEmptyArray(height, width);
data = plantMines(data, height, width, minesCount);
data = calculateMinesNeighbouring(data, height, width);
return data;
};
```
Three named functions, each with one job, composed in a clear order: create the board, place the mines, compute adjacency. This is **Single Responsibility** applied at the function level, and it makes the generation logic legible at a glance. You can read the pipeline and know what happens without reading the implementations.
Adjacency is factored out through `traverseGrid`, which returns the eight neighbours of a cell:
```js
const traverseGrid = (x, y, data: any) => {
const el: any[] = [];
if (x > 0) el.push(data[x - 1][y]); // up
if (x < state.height - 1) el.push(data[x + 1][y]); // down
if (y > 0) el.push(data[x][y - 1]); // left
if (y < state.width - 1) el.push(data[x][y + 1]); // right
if (x > 0 && y > 0) el.push(data[x - 1][y - 1]); // top left
if (x > 0 && y < state.width - 1) el.push(data[x - 1][y + 1]); // top right
if (x < state.height - 1 && y > 0) el.push(data[x + 1][y - 1]); // bottom left
if (x < state.height - 1 && y < state.width - 1) el.push(data[x + 1][y + 1]); // bottom right
return el;
};
```
I chose to enumerate all eight directions explicitly rather than derive them with a loop and modular arithmetic. This is **explicit over clever**. The loop version is shorter, but this version can be read and verified without simulating it in your head. For boundary logic — where off-by-one errors hide — that trade is worth making every time.
### Recursion with a guaranteed terminus
Revealing an empty cell cascades outward:
```js
const revealEmpty = (x, y, data) => {
traverseGrid(x, y, data).map(value => {
if (!value.isFlagged && !value.isRevealed && (value.isEmpty || !value.isMine)) {
data[value.x][value.y].isRevealed = true;
if (value.isEmpty) revealEmpty(value.x, value.y, data);
}
});
return data;
};
```
Termination is guaranteed by construction: the function only recurses into cells that are not yet revealed, and it marks each cell as revealed *before* recursing. The guard condition — not flagged, not already revealed, and safe — is the correct one. This is the classic flood-fill, and the invariant that makes it safe is that the set of unrevealed cells strictly decreases with each call.
### Type safety at the boundaries
I declared a TypeScript interface for the cell component's props:
```ts
interface GridSquareProps {
cMenu: any;
index: number;
isExplosive: boolean;
nearbyMinesCount: number;
uncovered: boolean;
value: any;
onClick: (index: any) => any;
}
```
The intent is an explicit contract for what a `GridSquare` expects. It documents the component's surface at the point of definition, and it means a missing or mistyped prop at the call site is caught by the compiler rather than at runtime. Even where types are loose, having the interface written down is the difference between a documented contract and an implicit one.
### Input handling as a distinct concern
Parsing the board specification is its own component with its own validation:
```js
const processSeed = (seed: string): Seed|null => {
let splitSeed = seed.split(",");
if (splitSeed.length < 3) return null;
for (let s in splitSeed) {
if (!isNumeric(splitSeed[s])) return null;
}
const [width, height, ...mineIndices] = splitSeed;
return [Number(width), Number(height), Number(mineIndices)] as Seed;
};
```
The component holds its own local input state, validates on submit, and only emits a start signal when the input parses cleanly. This keeps input concerns out of the game engine entirely — the engine receives a valid specification or nothing at all. It's the **separation of concerns** principle applied to user input: parsing, validation, and game logic are three different jobs, so they live in three different places.
### Interaction design
The two gestures are handled by two distinct handlers — `_handleGridSquareClick` bound to `onClick` and `_handleContextMenu` bound to `onContextMenu`. The latter calls `e.preventDefault()` to suppress the browser's native context menu, which is the correct approach for a right-click interaction.
Separating these into named handlers rather than inlining both in JSX keeps the render readable and the behaviours independently testable. Each handler does one thing and can be reasoned about in isolation.
### Conditional rendering driven by state
The `GameOver` overlay is gated behind a boolean condition:
```jsx
{(state.gameOver || state.gameWon) && (
<GameOver restartGame={restartGame} wonOrLost={state.gameWon ? "won" : "lost"} />
)}
```
This is idiomatic React: presence in the tree is driven by state, not by imperative DOM manipulation. The `GameOver` component itself receives only what it needs and stays simple as a result.
---
## Result
The application is functional, playable, and architecturally coherent. The component tree reflects the problem's natural seams. State is centralised and flows in one direction. Game logic is separated from presentation. Board generation is a readable pipeline of single-purpose functions. Recursion is bounded by an invariant that guarantees termination. The board is modelled as first-class data rather than as index arithmetic.
These are the decisions that make the *next* pass easy rather than painful. When the architecture is right, refinement is local: you change one function and the rest of the system absorbs it without complaint.
That's the real outcome of this exercise. It would have been possible to write the same game in a single component with everything inlined — and it would have worked, on the default input, for a while. Structuring it this way means the code can grow, be tested, and be reasoned about by someone who didn't write it.
### What I'm refining next
The structure is sound, which means the remaining work is hardening rather than re-architecting:
- **Extracting the rules into pure functions.** Board in, new board out. This makes the game logic testable without React and eliminates a whole class of rendering surprises.
- **Separating invariants from derived values.** Total mines is a constant; mines remaining is a derivation. Keeping them distinct clarifies both the display and the win condition.
- **Memoising the cell component.** `GridSquare` is a pure presentational component and a natural candidate for `React.memo`, which reduces re-render cost on larger boards.
- **Replacing `alert()` with the `GameOver` overlay.** The overlay already exists and is already wired into the tree — it just needs the state transition to match.
- **Adding tests around generation.** Dimension handling, mine placement, and adjacency counts are all pure and therefore trivially testable. That's where coverage pays for itself first.
- **Keyboard and screen-reader support.** The component structure already anticipates it; it's a matter of adding the handlers and ARIA attributes.
---
## Takeaway
I designed it around decomposition from the start. Separating the engine from the presentation, centralising state, naming functions descriptively, and modelling the board as data are the decisions that scale. A codebase with good bones can be tested, extended, and corrected. A codebase without them can't be, no matter how much of it works.
Minesweeper is a small game. But the principles it exercises — single responsibility, separation of concerns, unidirectional flow, explicit over clever, guarded recursion, state-driven rendering — are the same ones that govern systems many orders of magnitude larger. Practising them on a grid of hidden squares isn't a waste of time. It's rehearsal.