machine coding11
react ui components
state-machinetimersderived-state

Memory game

Build a card-matching memory game on a grid.

medium1 min readsolution

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