react ui components
state-machinetimersderived-state
Memory game
Build a card-matching memory game on a grid.
What it tests
Modelling a small state machine. Cards are face-down, or flipped and awaiting a comparison, or matched permanently — and the "two are flipped, compare them" step happens on a delay, during which input has to be ignored.
The approach
Track the flipped pair and the set of matched cards separately. When two cards
are face up, compare them and either add both to matched or flip both back
after a short timeout so the player can see what they picked.
Keeping matched as its own collection means a matched card's appearance is
derived rather than a third state stored on the card itself.
Where it usually goes wrong
- Letting a third card flip during the delay. Clicks have to be ignored while a comparison is pending, or the board desyncs.
- Shuffling during render. A fresh shuffle on every render reorders the board mid-game; it belongs in lazy initial state.
- Index as identity. With duplicate values by design, pairs need stable ids, not array positions.
- Uncleared timeouts. Unmounting mid-comparison sets state on a dead tree.
Full solution
2 files from src/folders/memorygame.
memory.jsx
import { useState } from "react";
import "./styles.css";
const cards = [
{ id: 1, value: "🍎" },
{ id: 2, value: "🍌" },
{ id: 3, value: "🍇" },
{ id: 4, value: "🍉" },
{ id: 5, value: "🍓" },
{ id: 6, value: "🍒" },
{ id: 7, value: "🥝" },
{ id: 8, value: "🍍" },
];
const gameData = [...cards, ...cards].map((c, idx) => ({
...c,
uid: idx, // unique id for each card
opened: false,
matched: false,
}));
export default function Memory() {
const [board, setBoard] = useState(gameData);
const [open, setOpen] = useState([]); // store indexes
function handleClick(idx) {
if (board[idx].matched || open.includes(idx)) return;
// open card
const newBoard = [...board];
newBoard[idx] = { ...newBoard[idx], opened: true };
setBoard(newBoard);
const newOpen = [...open, idx];
setOpen(newOpen);
// if 2 cards opened
if (newOpen.length === 2) {
const [firstIdx, secondIdx] = newOpen;
if (newBoard[firstIdx].value === newBoard[secondIdx].value) {
// match
setTimeout(() => {
setBoard((prev) => {
const updated = [...prev];
updated[firstIdx] = { ...updated[firstIdx], matched: true };
updated[secondIdx] = { ...updated[secondIdx], matched: true };
return updated;
});
setOpen([]);
}, 300);
} else {
// close both
setTimeout(() => {
setBoard((prev) => {
const updated = [...prev];
updated[firstIdx] = { ...updated[firstIdx], opened: false };
updated[secondIdx] = { ...updated[secondIdx], opened: false };
return updated;
});
setOpen([]);
}, 700);
}
}
}
return (
<div className="game">
{board.map((val, idx) => (
<li
className="card"
onClick={() => handleClick(idx)}
style={{ listStyle: "none" }}
key={val.uid}
>
{val.opened || val.matched ? val.value : "❓"}
</li>
))}
</div>
);
}styles.css
* {
padding-left: 10px;
}
.game {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 20px;
}
.card {
display: flex;
height: 150px;
width: 100px;
background-color: black;
padding: 20px;
border-radius: 10px;
align-items: center;
justify-content: center;
transition: 0.2s ease-in-out;
}share this post