Create a typing test in React

This interview question was asked in MakeMyTrip SDE2 frontend machine coding round.

The problem statement reads as: Typeracer is a game to improve typing speed.

  • The UI will have a screen where a random paragraph is there. The player has to type the words in the paragraph.
  • All characters are black initially.
  • Each correctly word character will turn green in color.
  • Each wrongly word character will turn red and all the subsequent characters will also turn red.
  • When all characters are word correctly, typing speed is displayed in words per minute and the game ends.

Based on the problem it is clear that we have to create a typing test web app like MonkeyType.

Let us see how we can implement the same in React.

Generating the paragraph

Breaking down the problem statement, I will first render the each character of the paragraph as a <span>i<span/> so that we can track and then mark it as valid, invalid, or not-typed.

I am using this txtgen package that provides random paragraph of the specified size.

npm i txtgen

We will generate this paragraph globally to avoid getting a new list on state update and then create an array of characters from this paragraph by splitting it and wrapping each character in span.

import { paragraph } from "txtgen";

const pg = paragraph([1]);

const lettersToHTML = (pg) => {
  const letters = pg.split("");
  return letters.map((e, i) => {
    return <span key={e + i}>{e}</span>;
  });
};

export default function App() {
  return (
    <div className="App">
      <p className="paragraph">{lettersToHTML(pg)}</p>
    </div>
  );
}
.paragraph {
  font-size: 1.5em;
}

This will generate each character from the paragraph in a individual span to which we can add classes to change its color and individually track it.

Generated inidividual character of the paragraph as span

Highlighting the characters in paragraph

Next is we will listen to the keydown event and track which key is pressed. We will store the character in the state and then pass this state to the lettersToHTML function to check if the right character is added or not and then accordingly added classes to highlight them.

We will also add a blink cursor to characters span to showcase at which character we are at currently.

import { paragraph } from "txtgen";
import { useState, useEffect } from "react";
import "./styles.css";

const pg = paragraph([1]);

const lettersToHTML = (pg, typedText = []) => {
  const letters = pg.split("");
  return letters.map((e, i) => {
    // get the current typed character and check if the matches
    const currentTypedCharacter = typedText[i];
    const valid = currentTypedCharacter?.toLowerCase() === e?.toLowerCase();

    const className = (() => {
      // base class when no character is typed
      if (typedText.length === 0) {
        return "black";
      }

      // Check for subseqeuent characters
      if (typedText.length <= i) {
        // This checks if the last typed character matches the on the original characters
        const lastTypedText = typedText[typedText.length - 1];
        const ogCharacterAtLastTypedTextIndex = letters[typedText.length - 1];
        const lastTypedCharacterInvalid =
          lastTypedText?.toLowerCase() !==
          ogCharacterAtLastTypedTextIndex?.toLowerCase();

        // if last typed character does not matches then make all the subseqeuent invalid
        return lastTypedCharacterInvalid ? "invalid" : "black";
      }

      // check if valid or not
      return valid ? "valid" : "invalid";
    })();

    // show blink before the current character
    const showBlink = i === typedText.length;

    return (
      <span
        key={e + i}
        className={showBlink ? className + " blink" : className}
      >
        {e}
      </span>
    );
  });
};

export default function App() {
  const [str, setStr] = useState([]);

  useEffect(() => {
    const onType = (e) => {
      if (e.key === "Backspace") {
        // remove the last character on each Backspace
        setStr((prev) => {
          prev.pop();
          return [...prev];
        });
      } else {
        // add to characters array
        setStr((prev) => [...prev, e.key]);
      }
    };

    window.addEventListener("keydown", onType);

    return () => {
      window.removeEventListener("keydown", onType);
    };
  }, []);

  return (
    <div className="App">
      <p className="paragraph">{lettersToHTML(pg, str)}</p>
    </div>
  );
}
.invalid {
  color: hsl(0, 100%, 50%);
  background-color: rgba(255, 0, 0, 0.2);
}

.valid {
  color: green;
}

.paragraph {
  font-size: 1.5em;
}

@keyframes blink {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

.blink::before {
  content: "";
  display: inline-block;
  height: 20px;
  width: 3px;
  background-color: #fd3d7c;
  animation-name: blink;
  animation-duration: 1s;
  animation-iteration-count: infinite;
  animation-timing-function: ease-in-out;
}

This handles all the cases like:

  • All characters are black initially.
  • Each correctly word character will turn green in color.
  • Each wrongly word character will turn red and all the subsequent characters will also turn red.

Highlighted, valid, invalid characters of the paragraph

For the third point, we have added check for lastTypedCharacterInvalid which if is invalid we highlight the subsequent characters.

Doing the calculation of word per minute

The final part pending is adding the calculation for word per minute. For this, we will maintain two separate states for start-time and end-time.

We will add a button on whose click the start-time will be set and then only we can start the typing. This button will reset the state once the typing is concluded.

   const onStartClick = () => {
    // if no endTime then start
    if (!endTime) {
      setStartTime(Date.now());
    }
    // reset if endTime is added
    // endTime is updated when the typing is completed
    // thus this is a safe check
    else {
      setStartTime(Date.now());
      setEndTime(0);
      setStr([]);
    }
  };

To check if the typing is concluded we will check if the typed characters length and the paragraph length are same and there are no mismatched characters.

 // check if there is any mismatch characters
  const mismatchCharacters = str.some((e, i) => {
    return e.toLowerCase() !== pg[i].toLowerCase();
  });

  // check if typing is completed
  const completedTyping = pg.length === str.length && !mismatchCharacters;

  // if typing is completed update the end time
  useEffect(() => {
    if (completedTyping) {
      setEndTime(Date.now());
    }
  }, [completedTyping]);

And the finally update the condition on the keydown event listener to only invoke if the typing has started and it is not concluded.

  useEffect(() => {
    const onType = (e) => {
      // base condition: terminate if typing is not started or it is concluded
      if (!startTime || completedTyping) {
        return;
      }

      if (e.key === "Backspace") {
        // remove the last character on each Backspace
        setStr((prev) => {
          prev.pop();
          return [...prev];
        });
      } else {
        // add to characters array
        setStr((prev) => [...prev, e.key]);
      }
    };

    window.addEventListener("keydown", onType);

    return () => {
      window.removeEventListener("keydown", onType);
    };
  }, [startTime, completedTyping]);

Then we will pass the words and the start-time and the end-time to the helper function that will calculate word typed per minute.

function calculateWPM(text, startTime, endTime) {
  // Count words by splitting on whitespace
  const wordCount = text.trim().split(/\s+/).length;

  // Time in minutes
  const timeInMinutes = (endTime - startTime) / 1000 / 60;

  // Avoid division by zero
  if (timeInMinutes === 0) return 0;

  // Calculate WPM
  const wpm = wordCount / timeInMinutes;

  return Math.round(wpm);
}

Final code

Putting everything together

import "./styles.css";
import { useEffect, useState } from "react";
import { paragraph } from "txtgen";

const pg = paragraph([1]);

function calculateWPM(text, startTime, endTime) {
  // Count words by splitting on whitespace
  const wordCount = text.trim().split(/\s+/).length;

  // Time in minutes
  const timeInMinutes = (endTime - startTime) / 1000 / 60;

  // Avoid division by zero
  if (timeInMinutes === 0) return 0;

  // Calculate WPM
  const wpm = wordCount / timeInMinutes;

  return Math.round(wpm);
}

const lettersToHTML = (pg, typedText = []) => {
  const letters = pg.split("");
  return letters.map((e, i) => {
    // get the current typed character and check if the matches
    const currentTypedCharacter = typedText[i];
    const valid = currentTypedCharacter?.toLowerCase() === e?.toLowerCase();

    const className = (() => {
      // base class when no character is typed
      if (typedText.length === 0) {
        return "black";
      }

      // Check for subseqeuent characters
      if (typedText.length <= i) {
        // This checks if the last typed character matches the on the original characters
        const lastTypedText = typedText[typedText.length - 1];
        const ogCharacterAtLastTypedTextIndex = letters[typedText.length - 1];
        const lastTypedCharacterInvalid =
          lastTypedText?.toLowerCase() !==
          ogCharacterAtLastTypedTextIndex?.toLowerCase();

        // if last typed character does not matches then make all the subseqeuent invalid
        return lastTypedCharacterInvalid ? "invalid" : "black";
      }

      // check if valid or not
      return valid ? "valid" : "invalid";
    })();

    // show blink before the current character
    const showBlink = i === typedText.length;

    return (
      <span
        key={e + i}
        className={showBlink ? className + " blink" : className}
      >
        {e}
      </span>
    );
  });
};

export default function App() {
  // track time and character typed
  const [startTime, setStartTime] = useState(0);
  const [endTime, setEndTime] = useState(0);
  const [str, setStr] = useState([]);

  // check if there is any mismatch characters
  const mismatchCharacters = str.some((e, i) => {
    return e.toLowerCase() !== pg[i].toLowerCase();
  });

  // check if typing is completed
  const completedTyping = pg.length === str.length && !mismatchCharacters;

  useEffect(() => {
    const onType = (e) => {
      // base condition: terminate if typing is not started or it is concluded
      if (!startTime || completedTyping) {
        return;
      }

      if (e.key === "Backspace") {
        // remove the last character on each Backspace
        setStr((prev) => {
          prev.pop();
          return [...prev];
        });
      } else {
        // add to characters array
        setStr((prev) => [...prev, e.key]);
      }
    };

    window.addEventListener("keydown", onType);

    return () => {
      window.removeEventListener("keydown", onType);
    };
  }, [startTime, completedTyping]);

  // if typing is completed update the end time
  useEffect(() => {
    if (completedTyping) {
      setEndTime(Date.now());
    }
  }, [completedTyping]);

  const onStartClick = () => {
    // if no endTime then start
    if (!endTime) {
      setStartTime(Date.now());
    }
    // reset if endTime is added
    // endTime is updated when the typing is completed
    // thus this is a safe check
    else {
      setStartTime(Date.now());
      setEndTime(0);
      setStr([]);
    }
  };

  return (
    <div className="App">
      <button onClick={onStartClick}>
        {endTime ? "Reset" : startTime ? "Started" : "Start"}
      </button>
      <p className="paragraph">{lettersToHTML(pg, str)}</p>
      {completedTyping && <p>WPM: {calculateWPM(pg, startTime, endTime)}</p>}
    </div>
  );
}

Typing text with highlighting in Reactjs

Note:- For simplicity I have done the case-insensitive check by converting both to the lower case, if you want you can remove this add the case sensitive check as well but for that you will need to handle the shift button press in the keydown event listener.