DEV Community

Cover image for Building a Typing Test App with React, TypeScript, and Redux Toolkit
Viktor
Viktor

Posted on

Building a Typing Test App with React, TypeScript, and Redux Toolkit

Good day, everyone! In this guide, we're going to build a typing test app using React, TypeScript, and Redux Toolkit.

This article will not cover the app's style creation process. However, if you are interested in seeing the full code, you can explore the project's repository.

What we're Building

Our goal is to make a typing test app where users can practice their typing skills. Here's an overview of what our app will do:

  1. Users can choose the number of sentences they want to practice.
  2. We'll retrieve sentences from an external API.
  3. Correct and incorrect letters will be visually distinguished.
  4. The currently typed letter will be highlighted.
  5. The app will tell users how fast and accurately they typed.
  6. Users can restart the typing test whenever they want.

Setting up the project

We'll start by creating a React project using a TypeScript template. To do this, open a terminal and run the following command: npx create-react-app typing-test-app --template typescript.

After that, install 'axios' by running npm install axios. Additionally, install Redux Toolkit and React Redux by running npm install @reduxjs/toolkit react-redux in the project's folder.

Proceed by removing any unnecessary files from the project directory. Essential files include index.tsx, App.tsx, index.css, and react-app-env.d.ts. Additionally, create the following folders: api, assets, components, helpers, redux, types, and style.

With the project structure in place, let's edit the remaining files.

index.tsx:

import React from 'react';
import ReactDOM from 'react-dom/client';

import './style/index.css';

import App from './App';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);

root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

App.tsx:

import { FunctionComponent } from 'react';

const App:FunctionComponent = () => {
  return (
    <>
    </>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

Since we are developing our app using TypeScript, it’s a good practice to use the built-in FunctionComponent type for creating functional components.

Header and Footer

Inside the components folder, let's create another folder called ui. Then inside this folder, create two components: Header and Footer.

Header.tsx

import { FunctionComponent } from 'react';

import logo from '../../assets/images/logo.svg';

import '../../style/ui/header.css';

const Header:FunctionComponent = () => {
  return (
    <header className='container header'>
      <div className='header-container'>
        <img src={logo} alt='site logo' />
        <h1 className='large-header'>Typing Test</h1>
      </div>
    </header>
  );
};

export default Header;
Enter fullscreen mode Exit fullscreen mode

Footer.tsx

import { FunctionComponent } from 'react';

import '../../style/ui/footer.css';

const Footer:FunctionComponent = () => {
  return (
    <footer className='container footer'>
      <p>Made by Viktor P. © 2023</p>
    </footer>
  );
};

export default Footer;
Enter fullscreen mode Exit fullscreen mode

Import these components into App.tsx.

import { FunctionComponent } from 'react';

import './style/typography.css';

import Header from './components/ui/Header';
import Footer from './components/ui/Footer';

const App:FunctionComponent = () => {
  return (
    <>
      <Header />
      <main className='container main'>
      </main>
      <Footer />
    </>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

Base components

In the components folder, let's create a component named Test. Initially, it will render the word "Test".

import { FunctionComponent } from 'react';

const Test:FunctionComponent = () => {
  return (
    <section>
      <h2 className='big-header'>Test</h2>
    </section>
  );
};
export default Test;
Enter fullscreen mode Exit fullscreen mode

In the ui folder, we’ll create two reusable components: Button and ModalWindow.

For the Button component, we’ll use the ComponentPropsWithoutRef built-in type to access the attributes of an HTML button element as props.

import { FunctionComponent, ComponentPropsWithoutRef } from 'react';

import '../../style/ui/buttons.css';

interface ButtonProps extends ComponentPropsWithoutRef<'button'> {
  btnText: string;
}

const Button:FunctionComponent<ButtonProps> = ( {btnText, ...props} ) => {
  return (
    <button
      className='uppercase-text base-button dark-button'
      {...props}
    >
      {btnText}
    </button>
  );
};

export default Button;
Enter fullscreen mode Exit fullscreen mode

The ModalWindow component will accept a title as a string and JSX children elements.

import { FunctionComponent } from 'react';

import '../../style/ui/modal.css';

type ModalWindowProps = {
  children: JSX.Element | JSX.Element[];
  title: string;
};

const ModalWindow:FunctionComponent<ModalWindowProps> = ( {children, title} ) => {
  return (
    <div className='modal-window-blackout'>
      <div className='modal-window'>
        <h2 className='big-header modal-window-text'>
          {title}
        </h2>
        {children}
      </div>
    </div>
  );
};

export default ModalWindow;
Enter fullscreen mode Exit fullscreen mode

Setting up Redux Toolkit

Before we start using these components in our app, we need to set up a state for the test. Let’s create a new folder named store inside the redux folder. In the store folder, we'll create a new file named testSlice.

First, we’ll describe a type for the state. We can use booleans for isTestStarted and isTestFinished, and string for sentences. Initially, isTestStarted and isTestFinished will be set to false, and sentences will hold the value '4'. Using the createSlice method, we create a new slice for the app. We require four reducers, one for each state and another one to reset all states to their default values.

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

type TestState = {
  isTestStarted: boolean;
  isTestFinished: boolean;
  sentences: string;
}

const initialState: TestState = {
  isTestStarted: false,
  isTestFinished: false,
  sentences: '4',
};

const testSlice = createSlice({
  name: 'testSlice',
  initialState,
  reducers: {
    setIsTestStarted(state, action: PayloadAction<boolean>) {
      state.isTestStarted = action.payload;
    },
    setIsTestFinished(state, action: PayloadAction<boolean>) {
      state.isTestFinished = action.payload;
    },
    setSentences(state, action: PayloadAction<string>) {
      state.sentences = action.payload;
    },
    resetTestState(state) {
      state.isTestStarted = false;
      state.isTestFinished = false;
      state.sentences = '4';
    }
  }
});

export const {
  setIsTestStarted,
  setIsTestFinished,
  setSentences,
  resetTestState
} = testSlice.actions;

export default testSlice.reducer;
Enter fullscreen mode Exit fullscreen mode

Next, let’s create a store for the app. In the store folder, add a new file named store and import textSlice into it. Since we're using TypeScript, we export two external types from the store, which will be useful for creating custom Redux hooks later on.

import { configureStore } from '@reduxjs/toolkit';

import testReducer from './testSlice';

const store = configureStore({
  reducer: {
    testSlice: testReducer,
  },
});

export default store;

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Enter fullscreen mode Exit fullscreen mode

Create a new file named hooks in the redux folder. Import RootState and AppDispatch into this file. And create two custom hooks: useAppDispatch and useAppSelector. These are essentially typed versions of the default Redux hooks useDispatch and useSelector.

import { useDispatch, useSelector } from 'react-redux';
import type { TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './store/store';

export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Enter fullscreen mode Exit fullscreen mode

Inside the index.tsx, wrap the App component with a Provider and provide the store to it.

import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';

import store from './redux/store/store';

import './style/index.css';

import App from './App';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);

root.render(
  <Provider store={store}>
    <App />
  </Provider>
);
Enter fullscreen mode Exit fullscreen mode

In App.tsx, bring in the custom hooks and functions: setIsTestStarted and setSentences. Also, import the Button and ModalWindow components. Create a new function named testStateToggler, which will switch the isTestStarted state to true.

We’ll use conditional rendering here. When isTestStarted is true, the Test component is shown. When isTestStarted is false, the ModalWindow component is displayed along with the Button component. The testStateToggler function is assigned to the Button's onClick event.

import { FunctionComponent } from 'react';

import './style/typography.css';

import { useAppSelector, useAppDispatch } from './redux/hooks';
import { setIsTestStarted, setSentences } from './redux/store/testSlice';

import Header from './components/ui/Header';
import Footer from './components/ui/Footer';
import Test from './components/Test';
import ModalWindow from './components/ui/ModalWindow';
import Button from './components/ui/Button';

const App:FunctionComponent = () => {
  const dispatch = useAppDispatch();
  const isTestStarted = useAppSelector(state => state.testSlice.isTestStarted);

  const testStateToggler = () => dispatch(setIsTestStarted(true));

  return (
    <>
      <Header />
      <main className='container main'>
        {
          isTestStarted
            ? <Test />
            : <ModalWindow title='Take a typing test'>
                <Button btnText='start' onClick={testStateToggler} />
              </ModalWindow>
        }
      </main>
      <Footer />
    </>
  );
}; 

export default App;
Enter fullscreen mode Exit fullscreen mode

Dealing with Text

We'll get a text from https://baconipsum.com/json-api/. To do this, we create a new file named getText in the api folder. In this file, we create an async function that sends a GET request to the API using axios. The response will be a string containing the text. The function will take the number of requested sentences as its argument.

import axios from 'axios';

async function getText(sentences: string) {
  const response = await axios.get<string>('https://baconipsum.com/api/', {
    params: {
      type: 'all-meat',
      sentences,
      format: 'text'
    }
  });

  return response;
}

export default getText;
Enter fullscreen mode Exit fullscreen mode

Next, let's define a type for the text array. Create a new file named types inside the types folder. This array will hold objects with two properties: char and class.

export type TextType = {
  char: string;
  class: string;
};
Enter fullscreen mode Exit fullscreen mode

To handle the text, we’ll create a new file named textSlice and import TextType and getText into it. We’ll also use the built-in createAsyncThunk method for managing asynchronous requests.

Let's begin with type description. We need an array for characters (text), indicator for the loading state (isLoading), potential errors (error), monitoring of the current character index (currentCharIndex), tracking user mistakes (mistakes), and counting button presses (pressingCount).

Using the createAsyncThunk method, we create a new function named fetchText. To type the createAsyncThunk method, we'll employ a generic object with three types. The initial two types are strings, as we pass a string to the function as an argument and return a string from it. The third type, rejectValue, becomes necessary for error management, should an error occur.

The createAsyncThunk method requires an action name and an async function to fetch the text. Inside the async function, we'll use our getText function. On successful request, we'll return the response.data. If it unsuccessful, we'll use the rejectWithValue function with an error message.

In this slice, we need five reducers: setText to modify the text array, setCurrentCharIndex and setMistakes to update the character index and mistake count, increasePressingCount to track total button presses, and resetTextState to reset states to their default values.

To manage the fetchText action, we’ll use an extraReducers object. While fetching the text, we’ll set isLoading to true and error to null. After receiving the text, we’ll parse it, divide it into individual symbols, and form an array of objects. This array will store in the text state. The class field for the symbol at index 0 will be set to 'current-char'. In case of an error, we’ll update the error state with an error message.

import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';

import getText from '../../api/getText';

import { TextType } from '../../types/types';

type TextState = {
  text: TextType[];
  isLoading: boolean;
  error: string | null | undefined;
  currentCharIndex: number;
  mistakes: number;
  pressingCount: number;
};

export const fetchText = createAsyncThunk<string, string, {rejectValue: string}>(
  'textSlice/fetchText',
  async function(sentences: string, {rejectWithValue}) {
    try {
      const response = await getText(sentences);
      return response.data;
    }
    catch (e) {
      return rejectWithValue( (e as Error).message );
    }
  }
);

const initialState: TextState = {
  text: [],
  isLoading: false,
  error: null,
  currentCharIndex: 0,
  mistakes: 0,
  pressingCount: 0
};

const textSlice = createSlice({
  name: 'textSlice',
  initialState,
  reducers: {
    setText(state, action: PayloadAction<TextType[]>) {
      state.text = action.payload;
    },
    setCurrentCharIndex(state, action: PayloadAction<number>) {
      state.currentCharIndex = action.payload;
    },
    setMistakes(state, action: PayloadAction<number>) {
      state.mistakes = action.payload;
    },
    increasePressingCount(state) {
      state.pressingCount = state.pressingCount + 1;
    },
    resetTextState(state) {
      state.currentCharIndex = 0;
      state.mistakes = 0;
      state.pressingCount = 0;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchText.pending, (state) => {
        state.isLoading = true;
        state.error = null;
      })
      .addCase(fetchText.fulfilled, (state, action) => {
        state.text = action.payload.split('').map((item, index) => {
          return index === 0
            ? {char: item, class: 'current-char'}
            : {char: item, class: ''}
        });
        state.isLoading = false;
      })
      .addCase(fetchText.rejected, (state, action) => {
        state.isLoading = false;
        state.error = action.payload;
      });
  }
});

export const {
  setText,
  setCurrentCharIndex,
  setMistakes,
  increasePressingCount,
  resetTextState
} = textSlice.actions;

export default textSlice.reducer;
Enter fullscreen mode Exit fullscreen mode

Import textSlice into the store.

import { configureStore } from '@reduxjs/toolkit';

import testReducer from './testSlice';
import textReducer from './textSlice';

const store = configureStore({
  reducer: {
    testSlice: testReducer,
    textSlice: textReducer,
  },
});

export default store;

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Enter fullscreen mode Exit fullscreen mode

After that, we’ll create two new functions. The first one will apply CSS class to the current symbol. The second function will verify whether a button press matches the current symbol, and then apply the appropriate styling to indicate correctness or incorrectness.

Let’s create a new file named charTransform inside the helpers folder. We’ll begin with a getCurrentChar function. This function will accept the TextType array and the index of the current element as its arguments. It’ll generate a new TextType array as its output. Inside the function, the array will be processed. If the index of the current element matches an element's index in the array, that element with the 'current-char' class will return.

After that, we’ll create a compareChars function. It’ll take the TextType array, the index of the current element, the mistake count, and the pressed button as its arguments. Within the function, we’ll follow a similar process as the getCurrentChar function – parse the array and verify if the button press is accurate. This function will return a new array, an updated current character index, and a modified mistake count.

import { TextType } from '../types/types';

type GetCurrentCharType = (
  charsArray: TextType[],
    currentIndex: number
) => TextType[];

type CompareCharsType = (
  charsArray: TextType[],
    currentIndex: number,
    pressedKey: string,
    mistakes: number,
) => [
  resultArr: TextType[],
  currentIndex: number,
  mistakes: number
];

export const getCurrentChar: GetCurrentCharType = (charsArray, currentIndex) => {
  return charsArray.map((item, index) => {
    if (index === currentIndex) {
      return {
        ...item,
        class: 'current-char'
      };
    }

    return item;
  });
};

export const compareChars: CompareCharsType = (charsArray, currentIndex, pressedKey, mistakes) => {
  let newCurrentIndex = currentIndex;
  let newMistakes = mistakes;

  const resultArr = charsArray.map((item, index) => {
    if (index === currentIndex && item.char === pressedKey) {
      newCurrentIndex += 1;
      return {
        ...item,
        class: 'right-char'
      };
    } else if (index === currentIndex && item.char !== pressedKey) {
      newMistakes += 1;
      return {
        ...item,
        class: 'wrong-char'
      };
    }

    return item;
  });

  return [resultArr, newCurrentIndex, newMistakes];
Enter fullscreen mode Exit fullscreen mode

Next, in the components folder we’ll create a new component named Text. Here, we import the getCurrentChar and compareChars functions. Also, we import fetchText, setText, setCurrentCharIndex, increasePressingCount, and setMistakes from textSlice.

To fetch the text, we’ll use the useEffect hook. Within this hook, we’ll invoke the fetchText function, passing the sentences variable as its argument.

Additionally, we'll implement another useEffect. Inside it, we’ll call the getCurrentChar function and modify the text array, but only when the currentCharIndex changes.

To handle the keypress event, we'll utilize the useEffect hook once again. We'll compare the currentCharIndex with the length of the text array. If the currentCharIndex is smaller, we'll create a handler function. Inside this function, we'll invoke the compareChars function and update the text, currentCharIndex, and mistakes variables accordingly.

We’ll also use conditional rendering in this component. If an error occurs during the fetch request, we’ll display the error message. If the text is loading, we’ll show a paragraph saying 'Loading text...’. Once the text is retrieved, it will be rendered.

import { FunctionComponent, useEffect } from 'react';

import { useAppDispatch, useAppSelector } from '../redux/hooks';
import { fetchText, setText, setCurrentCharIndex, increasePressingCount, setMistakes } from '../redux/store/textSlice';

import { getCurrentChar, compareChars } from '../helpers/charTransform';

const Text:FunctionComponent = () => {
  const dispatch = useAppDispatch();
  const text = useAppSelector(state => state.textSlice.text);
  const isLoading = useAppSelector(state => state.textSlice.isLoading);
  const error = useAppSelector(state => state.textSlice.error);
  const currentCharIndex = useAppSelector(state => state.textSlice.currentCharIndex);
  const mistakes = useAppSelector(state => state.textSlice.mistakes);
  const pressingCount = useAppSelector(state => state.textSlice.pressingCount);
  const sentences = useAppSelector(state => state.testSlice.sentences);

  useEffect(() => {
    dispatch(fetchText(sentences));
  }, [dispatch]);

  useEffect(() => {
    const newText = getCurrentChar(text, currentCharIndex);
    dispatch(setText(newText));
  }, [dispatch, currentCharIndex]);

  useEffect(() => {
    if (currentCharIndex < text.length) {
      const keyPressHandler = (event: KeyboardEvent) => {
        const [newText, newCurrentIndex, newMistakes] = compareChars(text, currentCharIndex, event.key, mistakes);

        dispatch(setCurrentCharIndex(newCurrentIndex));
        dispatch(setText(newText));
        dispatch(setMistakes(newMistakes));
        dispatch(increasePressingCount());
      }

      document.addEventListener('keypress', keyPressHandler);

      return () => {
        document.removeEventListener('keypress', keyPressHandler);
      };
    }
  }, [dispatch, text]);

  return (
    <div className='test-text-wrapper'>
      {
        error &&
          <p className='error-text'>{error}</p>
      }
      {
        isLoading
          ? <p className='test-loading-text'>Loading text...</p>
          : <div>
              {
                text.map((item, index) => {
                  return (
                    <span className={item.class} key={index}>
                      {item.char}
                    </span>
                  )
                })
              }
            </div>
      }
    </div>
  );
}; 

export default Text;
Enter fullscreen mode Exit fullscreen mode

Import this new component into the Test component.

import { FunctionComponent } from 'react';

import '../style/test.css';

import Text from './Text';

const Test:FunctionComponent = () => {
  return (
    <section className='test-container'>
      <Text />
    </section>
  );
};

export default Test;
Enter fullscreen mode Exit fullscreen mode

Accuracy and speed counting

To track typing speed, we need to create a timer. Let’s create a new timerSlice to manage this timer. Initially, the timer will be off and seconds will be 0. Within this slice, we need three reducers. The first will change the timer's status, the second will count the seconds, and the third will reset the seconds to zero.

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

type TimerState = {
  isTimerOn: boolean;
  seconds: number;
}

const initialState: TimerState = {
  isTimerOn: false,
  seconds: 0,
};

const timerSlice = createSlice({
  name: 'timerSlice',
  initialState,
  reducers: {
    setIsTimerOn(state, action: PayloadAction<boolean>) {
      state.isTimerOn = action.payload;
    },
    increaseSeconds(state) {
      state.seconds = state.seconds + 1;
    },
    resetSeconds(state) {
      state.seconds = 0;
    },
  }
});

export const { setIsTimerOn, increaseSeconds, resetSeconds } = timerSlice.actions;

export default timerSlice.reducer;
Enter fullscreen mode Exit fullscreen mode

Import this slice into the store.

import { configureStore } from '@reduxjs/toolkit';

import testReducer from './testSlice';
import textReducer from './textSlice';
import timerReducer from './timerSlice';

const store = configureStore({
  reducer: {
    testSlice: testReducer,
    textSlice: textReducer,
    timerSlice: timerReducer
  },
});

export default store;

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Enter fullscreen mode Exit fullscreen mode

Next, within the Text component, before creating the keyPressHandler function, we’ll verify if it's the first button press. If so, we’ll turn on the timer. In the keyPressHandler function, we’ll check whether the new current character index matches the text's length. If they're equal, we’ll deactivate the timer and set isTestFinished to true.

...

useEffect(() => {
  if (pressingCount === 0 && text.length > 0) {
    dispatch(setIsTimerOn(true));
  }

  if (currentCharIndex < text.length) {
    const keyPressHandler = (event: KeyboardEvent) => {
      const [newText, newCurrentIndex, newMistakes] = compareChars(text, currentCharIndex, event.key, mistakes);

      dispatch(setCurrentCharIndex(newCurrentIndex));
      dispatch(setText(newText));
      dispatch(setMistakes(newMistakes));
      dispatch(increasePressingCount());

      if (newCurrentIndex === text.length) {
        dispatch(setIsTimerOn(false));
        dispatch(setIsTestFinished(true));
      }
    }

    document.addEventListener('keypress', keyPressHandler);

    return () => {
      document.removeEventListener('keypress', keyPressHandler);
    };
  }
}, [dispatch, text]);

...
Enter fullscreen mode Exit fullscreen mode

Then, we’ll create a new file named statsCounting inside the helpers folder. In this file, we'll have two functions. The first one, accuracyCounting, will take the count of mistakes and the total number of button presses. It’ll calculate and return the percentage of accurately pressed buttons.

The second function, speedCounting, will take the count of correctly typed symbols and the number of seconds. It’ll compute and return the typing speed in words per minute. To calculate the typing speed, we’ll convert seconds to minutes, and symbols to words (typically considering the average word length of five characters).

export function accuracyCounting(mistakes: number, pressingCount: number) {
  if (pressingCount) {
    return (100 - ((mistakes / pressingCount) * 100)).toFixed(2);
  }

  return '0.00';
}

export function speedCounting(correctLetters: number, seconds: number) {
  if (seconds) {
    const words = correctLetters / 5;
    const minutes = seconds / 60;

    return (words / minutes).toFixed(2);
  }

  return '0.00';
}
Enter fullscreen mode Exit fullscreen mode

Next we’ll create a new Stats component to present the user's statistics. In this component, we’ll import speedCounting, accuracyCounting, and increaseSeconds. This component will only receive JSX children elements as props.

We’ll create a local state for speed and accuracy using the useState hook. Additionally, we’ll utilize the useEffect hook, it'll triggered by changes in mistakes, pressingCount, or seconds. Within this hook, we’ll invoke the speedCounting and accuracyCounting functions, updating the speed and accuracy states accordingly.

Another useEffect will be needed to interact with the timer. It’ll check if the timer is active, and if so, it’ll increment the seconds.

import { FunctionComponent, useState, useEffect } from 'react';

import '../style/stats.css';

import { useAppDispatch, useAppSelector } from '../redux/hooks';
import { increaseSeconds } from '../redux/store/timerSlice';

import { speedCounting, accuracyCounting } from '../helpers/statsCounting';

type StatsProps = {
  children?: JSX.Element | JSX.Element[];
};

const Stats:FunctionComponent<StatsProps> = ( {children} ) => {
  const dispatch = useAppDispatch();
  const mistakes = useAppSelector(state => state.textSlice.mistakes);
  const pressingCount = useAppSelector(state => state.textSlice.pressingCount);
  const seconds = useAppSelector(state => state.timerSlice.seconds);
  const isTimerOn = useAppSelector(state => state.timerSlice.isTimerOn);
  const [speed, setSpeed] = useState('0.00');
  const [accuracy, setAccuracy] = useState('0.00');

  useEffect(() => {
    const correctLetters = pressingCount - mistakes;

    setAccuracy(accuracyCounting(mistakes, pressingCount));
    setSpeed(speedCounting(correctLetters, seconds));
  }, [mistakes, pressingCount, seconds]);

  useEffect(() => {
    if (isTimerOn) {
      const timer = setTimeout(() => {
        dispatch(increaseSeconds());
      }, 1000);
      return () => clearTimeout(timer);
    }
  }, [isTimerOn, seconds, dispatch]);

  return (
    <div className='stats-container'>
      <div>
        <p className='mid-header uppercase-text stat-title'>speed</p>
        <p className='uppercase-text paragraph'>{speed} WPM</p>
      </div>
      <div>
        <p className='mid-header uppercase-text stat-title'>accuracy</p>
        <p className='uppercase-text paragraph'>{accuracy} %</p>
      </div>
      {children}
    </div>
  );
};

export default Stats;
Enter fullscreen mode Exit fullscreen mode

Import this component into the Test component.

import { FunctionComponent } from 'react';

import '../style/test.css';

import Text from './Text';
import Stats from './Stats';

const Test:FunctionComponent = () => {
return (
    <section className='test-container'>
      <Text />
      <Stats />
    </section>
  );
};
Enter fullscreen mode Exit fullscreen mode

Finishing the Test

Within the charTransform file, we’ll implement a new function named restoreText. This function will take and return the TextType array. The function's purpose is to reset the text array to its default state.

......

type restoreTextType = (
  charsArray: TextType[],
) => TextType[];

......

export const restoreText: restoreTextType = (charsArray) => {
  return charsArray.map((item, index) => {
    if (index === 0) {
      return {
        ...item,
        class: 'current-char'
      };
    }

    return {
      ...item,
      class: ''
    };
  });
};
Enter fullscreen mode Exit fullscreen mode

In the Test component, we’ll create two new functions: restart and newTest. We’ll also apply conditional rendering here. If isTestFinished is true, we’ll render the ModalWindow component containing the Stats component, along with 'Restart' and 'New Test' buttons.

import { FunctionComponent } from  
'react';

import '../style/test.css';

import { useAppDispatch, useAppSelector } from '../redux/hooks';
import { resetSeconds } from '../redux/store/timerSlice';
import { setIsTestFinished, resetTestState } from '../redux/store/testSlice';
import { resetTextState, setText } from '../redux/store/textSlice';

import { restoreText } from '../helpers/charTransform';

import Text from './Text';
import Stats from './Stats';
import ModalWindow from './ui/ModalWindow';
import Button from './ui/Button';

const Test:FunctionComponent = () => {
  const dispatch = useAppDispatch();
  const isTestFinished = useAppSelector(state => state.testSlice.isTestFinished);
  const text = useAppSelector(state => state.textSlice.text);

  function restart() {
    dispatch(resetSeconds());
    dispatch(resetTextState());
    dispatch(setText(restoreText(text)));

    if (isTestFinished) {
      dispatch(setIsTestFinished(false));
    }
  }

  function newTest() {
    dispatch(resetTestState());
    dispatch(resetTextState());
    dispatch(resetSeconds());
  }

  return (
    <section className='test-container'>
      <Text />
      <Stats>
        <Button
          btnText='restart'
          onClick={restart}
          onFocus={(event) => event.target.blur()}
        />
      </Stats>
      {
        isTestFinished &&
          <ModalWindow title='Test completed!'>
            <Stats />
            <Button btnText='restart' onClick={restart}/>
            <Button btnText='new test' onClick={newTest}/>
          </ModalWindow>
      }
    </section>
  );
};

export default Test; 
Enter fullscreen mode Exit fullscreen mode

Chose the Number of Sentences

Finally, we’ll include an option to select the number of sentences before beginning the test. Let’s create a new component named Select inside the components/ui folder. Similar to the Button component, we’ll also use the ComponentPropsWithoutRef type here.

import { ComponentPropsWithoutRef } from 'react';

import '../../style/ui/select.css';

interface SelectProps extends ComponentPropsWithoutRef<'select'> {
  defaultValue: string;
  options: {
    value: string,
    name: string
  }[];
}

const Select:React.FC<SelectProps> = ( {defaultValue, options, ...props} ) => {
  return (
    <select
      className='uppercase-text paragraph select'
      defaultValue={defaultValue}
      {...props}
    >
      {
        options.map(option => {
          return (
            <option
              key={option.value}
              value={option.value}
            >
              {option.name}
            </option>
          );
        })
      }
    </select>
  );
};

export default Select;
Enter fullscreen mode Exit fullscreen mode

Now, we’ll import this component within App.tsx. In App.tsx we'll form a new array named options. The options array will consist of objects with two fields: 'value' and 'name'. We’ll provide this array to the Select component. For the onChange event of the Select component, we’ll assign the setSentences function.

import { FunctionComponent } from 'react';

import './style/typography.css';

import { useAppSelector, useAppDispatch } from './redux/hooks';
import { setIsTestStarted, setSentences } from './redux/store/testSlice';

import Header from './components/ui/Header';
import Footer from './components/ui/Footer';
import Test from './components/Test';
import ModalWindow from './components/ui/ModalWindow';
import Button from './components/ui/Button';
import Select from './components/ui/Select';

const App:FunctionComponent = () => {
  const dispatch = useAppDispatch();
  const isTestStarted = useAppSelector(state => state.testSlice.isTestStarted);
  const sentences = useAppSelector(state => state.testSlice.sentences);
  const sentencesOptions = [
    {value: '1', name: '1'},
    {value: '2', name: '2'},
    {value: '3', name: '3'},
    {value: '4', name: '4'},
    {value: '5', name: '5'},
  ];

  const testStateToggler = () => dispatch(setIsTestStarted(true));
  const changeSentences = (value: string) => dispatch(setSentences(value));

  return (
    <>
      <Header />
      <main className='container main'>
        {
          isTestStarted
            ? <Test />
            : <ModalWindow title='Take a typing test'>
                <label className='paragraph' htmlFor='select-senteces'>
                  Choose number of sentences
                </label>
                <Select
                  id='select-senteces'
                  defaultValue={sentences}
                  options={sentencesOptions}
                  onChange={(event) => changeSentences(event.target.value)}
                />
                <Button btnText='start' onClick={testStateToggler} />
              </ModalWindow>
        }
      </main>
      <Footer />
    </>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

In conclusion, that's all. The app is now fully functional.

P.S.

I'd appreciate your feedback and suggestions. Feel free to ask any questions you might have. Thank you!

Project’s repo
Live

Top comments (0)