Back to Blog

Three critical React lessons I learned building a hangman game

Building a simple hangman game unexpectedly revealed critical React pitfalls: state management traps, race conditions, and key anti-patterns that break apps.

By Marios Sofokleous
Published
Screenshot of a React hangman game interface showing the gallows, word display with dashes, and virtual keyboard

While working through Scrimba's Learn React course, I encountered a capstone project that sparked an idea. The original project was a guessing game where players have 8 attempts to guess a word, losing a programming language with each incorrect guess until only Assembly remains - a humorous nod to the difficulty of using Assembly for larger projects due to its low-level nature.

But as soon as I saw the intro video, I immediately recognized the similarities to the classic hangman game and decided to build that instead. Little did I know this "simple" project would teach me important lessons about state management, race conditions, and component design.

The game is built with React and includes several packages: react-loading-skeleton for visual loading indicators, lucide-react for icons, and react-confetti-explosion for celebratory particles when the game is won. I've implemented extra features differentiating it from the capstone project, including fetching random words via the Random Word API, UX and accessibility improvements, light and dark mode switching with customizable CSS variables, and a reusable Dialog component for both win and lose situations. The styling is done with Sass using BEM methodology.

See it in action

You can play the finished game right here:

The ASCII art solution

Rather than dealing with complex SVG animations or canvas drawing (which was outside my learning scope), I found an elegant solution: representing the hangman using ASCII art.

The HANGMAN_STAGES array contains 7 items, each representing the human figure at different stages. The first item shows the empty gallows with rope, and each subsequent item adds a human part:

export const HANGMAN_STAGES = [
  // Stage 0: Empty gallows with rope
  ` +---+
 |   |
     |
     |
     |
     |
=======
`,
  
  // ... (5 more stages) ...

  // Stage 6: Game over
  ` +---+
 |   |
 O   |
/|\\  |
/ \\  |
     |
=======
`,
];

This approach kept the focus on React concepts while still providing visual feedback for the game state.

Key lessons learned

1. Use state variables sparingly

A key lesson from this project was learning to minimize state variables by deriving values from existing state. This prevents bugs, makes code more maintainable, and improves performance by reducing unnecessary re-renders.

Here's my approach:

// State values - only what's truly necessary
const [secretWord, setSecretWord] = useState("");
const [guessedLetters, setGuessedLetters] = useState([]);

// Derived from state - computed on every render
const wrongGuessCount = guessedLetters.filter(
  (letter) => !secretWord.includes(letter)
).length;
const uniqueSecretLetters = Array.from(new Set(secretWord));
const isGameWon =
  secretWord &&
  uniqueSecretLetters.every((secretLetter) =>
    guessedLetters.includes(secretLetter)
  );
const isGameLost = wrongGuessCount >= HANGMAN_STAGES.length - 1;
const isGameOver = isGameWon || isGameLost;
const lastGuess = guessedLetters.at(-1);
const lastGuessWasIncorrect = lastGuess
  ? !secretWord.includes(lastGuess)
  : false;

A beginner might be tempted to create separate state for each of these values:

// ❌ Too much state - synchronization nightmare
const [secretWord, setSecretWord] = useState("");
const [guessedLetters, setGuessedLetters] = useState([]);
const [wrongGuessCount, setWrongGuessCount] = useState(0);
const [isGameWon, setIsGameWon] = useState(false);
const [isGameLost, setIsGameLost] = useState(false);
const [isGameOver, setIsGameOver] = useState(false);
const [lastGuess, setLastGuess] = useState("");

// You'd need complex useEffect chains to keep everything synchronized
useEffect(() => {
  const wrongCount = guessedLetters.filter(
    letter => !secretWord.includes(letter)
  ).length;
  setWrongGuessCount(wrongCount);
  
  const gameWon = secretWord && uniqueSecretLetters.every(...);
  setIsGameWon(gameWon);
  
  const gameLost = wrongCount >= HANGMAN_STAGES.length - 1;
  setIsGameLost(gameLost);
  
  setIsGameOver(gameWon || gameLost);
  setLastGuess(guessedLetters.at(-1) || "");
}, [secretWord, guessedLetters]);

2. Understanding React keys properly

I discovered I was incorrectly assigning keys when rendering lists. Even though the rules are straightforward - keys must be stable between renders - I was using an anti-pattern of generating keys on the fly during render.

The problem

Here's what I was doing wrong:

// ❌ Never generate keys during render
const letters = Array.from(secretWord).map((letter) => {
  const isGuessed = guessedLetters.includes(letter);
  return (
    <span key={crypto.randomUUID()} className="word-display__letter">
      {isGuessed || isGameLost ? letter.toUpperCase() : "-"}
    </span>
  );
});

Why this causes issues

When keys are generated on every render, React can't efficiently track changes, leading to confusing bugs and unnecessary performance overhead from re-rendering the entire list.

The solution

I opted to use the item's index instead:

// ✅ Stable keys for this specific use case
const letters = Array.from(secretWord).map((letter, idx) => {
  const isGuessed = guessedLetters.includes(letter);
  return (
    <span key={idx} className="word-display__letter">
      {isGuessed || isGameLost ? letter.toUpperCase() : "-"}
    </span>
  );
});

Why this works

While using index keys is generally not recommended, it's perfectly safe in this case because the letters never change, reorder, or get added/removed during gameplay. However, index keys should always be your last resort - there's always a risk of missing edge cases that could make index keys unstable.

The safest approach is to use database keys/IDs, which are inherently stable and eliminate the risk of key-related rendering issues.

3. The race condition I didn't see coming

The most surprising lesson came from something I didn't expect: Strict Mode exposed a race condition in my API calls.

The problem

Here's the code I initially thought was bulletproof:

// ❌ Vulnerable to race conditions in Strict Mode
useEffect(() => {
  async function startFetching() {
    setIsLoading(true);
    const word = await fetchSecretWord();
    console.log(word);
    setSecretWord(word);
    setIsLoading(false);
  }
  startFetching();
}, []);

What happens in development

Timeline:
1. Component mounts → `useEffect` runs → API call #1 starts
2. React Strict Mode triggers remount → `useEffect` runs again → API call #2 starts
3. Response #2 arrives first → `setSecretWord("apple")`
4. Response #1 arrives later → `setSecretWord("banana")`
Result: Game shows "banana" but the correct word was "apple"

This created unpredictable behavior during development, doubled API calls, and confusing debugging sessions.

The solution: The ignore flag pattern

I learned this pattern directly from the React documentation:

// ✅ Strict Mode safe
useEffect(() => {
  let ignore = false;  // The key to preventing stale updates
  
  async function startFetching() {
    setIsLoading(true);
    const word = await fetchSecretWord();
    console.log(word);
    
    // Only update if this effect execution is still current
    if (!ignore) {
      setSecretWord(word);
      setIsLoading(false);
    }
  }
  
  startFetching();
  
  // Cleanup: mark this execution as stale
  return () => {
    ignore = true;
  };
}, []);

How the pattern works

1. First `useEffect` → `ignore1 = false` → API call #1 starts
2. First cleanup → `ignore1 = true` (call #1 now ignored)
3. Second `useEffect` → `ignore2 = false` → API call #2 starts
4. Response #1 arrives → `ignore1 = true` → setState skipped
5. Response #2 arrives → `ignore2 = false` → setState executes
Result: Only the most recent API call affects the game state

This pattern provides a consistent development experience, prevents wasted API calls from affecting game state, and protects against future refactoring when adding dependencies to useEffect later.

For components that frequently mount and unmount, this pattern also prevents state updates on unmounted components and ensures that only the most recent data updates the UI, avoiding the common problem where stale data overwrites fresh data.

Even though my App component seemed immune to race conditions, Strict Mode revealed a subtle but important vulnerability. The ignore flag pattern is a simple, zero-overhead solution for reliably fetching data with Effects.

This project reinforced that even "simple" apps can teach profound lessons about React's behavior and best practices.


Share this page
Back to Blog

Let's connect

Interested in adding me to your development team?