Let's build a quiz app using React Hooks and Typescript. This little application will help us learn how to use React Hooks and Typescript in progressively complex ways. Until using them comes easily and finally 'hooks' into our brains.
So let's get in there: 🧠🧠ðŸ§
What are React Hooks?
React Hooks were stabilized and shipped with React 16.8. Without Hooks, React Functional Components would not have a means to use React Lifecycle methods (RLMs), among other things. And instead, you'd have to use Class Components to use the RLMs.
But React Hooks right that ship. With them you can 'hook' into React Lifecycle Methods, manage your application state, and even create reducers on the fly without having to use heavier (but equally great, and sometimes preferable) state-management libraries such as Redux.
Why add Typescript?
JavaScript is a weakly typed language, which means that on variable declaration you aren't required to declare its type, and JavaScript will change the type of a variable as your whims change. For example, here's something we may try (if we had no idea what we were doing, perhaps? Been there!):
let number = '22' // variable is a string
number = {
age: 22,
birthdayYear: 2020
}
console.log(number) // variable is now an object { age: 22, birthdayYear: 2020 }
JavaScript just shrugs... ¯_(ツ)_/¯ This loosey-goosey behavior can, overall, make applications buggier and far harder to maintain. Typescript stops this madness. As a superset of JavaScript, Typescript evaluates your code during compile-time (as opposed to run-time) to be sure your variables and props are what you intend them to be. So the above will turn into this:
let number: string = '22' // variable is a string
number = {
age: 22,
birthdayYear: 2020
} // Type '{ age: number; birthdayYear: number; }' is not assignable to type 'string'.
Typescript says, oh heck NO! 😱 And thank goodness it does! It may seem like a chore to add Typescript and type declarations on smaller applications, but as you build larger applications and work with more engineers, it will go a long way to writing cleaner, more-testable code. So now that we know why it may be good to use React Hooks and Typescript, let's start writing some code!
Setting up the app
I wont go into great detail about setup, because that's the easy part. But easiest route is to use Create React App (CRA); another resource: CRA Github page. For CRA, in your terminal, type this:
npx create-react-app my-app
cd my-app
Now install typescript and the other packages you may need if you'll also use Lodash, Jest, or Sass:
npm install --save typescript @types/jest @types/lodash @types/node @types/node-sass @types/react @types/react-dom lodash node-sass react-dom
npm install // to install all the packages in package.json file
Then type
npm start // to run the app at http://localhost:3000
To officially change the app to Typescript, change the .js
files in which you'll use JSX into .tsx
files. Any other .js
file can become a .ts
file.
// src/components/App.tsx
import React, { FunctionComponent } from 'react';
import MainContainer from './Main';
const App: FunctionComponent = () => {
return (
<div>
<MainContainer />
</div>
);
};
export default App;
The FunctionComponent
type declaration is a Typescript typing for a functional component. Previously, you'd type it as a StatelessComponent
, but that is now deprecated because technically any functional component can now have state.
This next component is the MainContainer. Here we'll import useEffect, useState
from React to start using our Hooks. In the below file, you'll see const MainContainer: FunctionComponent<{ initial?: Models.QuizResponse; }> = ({ initial })
. This sets the type of the FunctionComponent
and also declares the data type we expect back from the data fetch
to opentdb.com: Models.QuizResponse
.
useEffect
is a React hook that allows side effects in function components and enables access to the React Lifecycle Methods (RLMs) componenDidMount(), componentDidUpdate(),
and componentWillUnmount()
all in one. See more about the React useEffect() Hook in the docs.
// src/components/MainContainer.tsx
import React, { FunctionComponent, useEffect, useState } from 'react';
import * as Models from './../models';
import Card from './Card';
import './Main.scss';
const MainContainer: FunctionComponent<{ initial?: Models.QuizResponse; }> = ({ initial }) => {
// Below is one way state is set using React Hooks, where the first deconstructed variable`quizzes` is the state variable name
// and `setQuizzes` is the methodName called to update the quizzes state if needed. Here, use it after the data is fetched successfully.
const [quizzes, setQuizzes] = useState(initial);
const [shouldShowCards, setShouldShowCards] = useState(false);
const fetchData = async (): Promise<void> => {
const res = await fetch('https://opentdb.com/api.php?amount=10&type=boolean');
res.json()
.then((res) => setQuizzes(res))
.catch((err) => console.log(err));
};
// useEffect is a React hook that allows side effects in function components and enables the React Lifecycle Method (RLM)
// componenDidMount(), componentDidUpdate(), and componentWillUnmount() lifecycles combined. See more about
// the [React useEffect() Hook](https://reactjs.org/docs/hooks-effect.html) in the docs.
useEffect(() => {
fetchData();
}, []);
const handleButtonClick = (): void => {
setShouldShowCards(true);
};
return (
<main className='Main'>
{!shouldShowCards ? (
<>
<h2>Welcome to the Trivia Challenge!</h2>
<div className='StartEndCard'>
<h2>You will answer 10 of the most rando true or false questions</h2>
<p>Can you score 10/10?</p>
<button type='submit' className='Button' onClick={() => handleButtonClick()}>Get Started!</button>
</div>
</>
) : <Card quizQuestions={quizzes} />}
</main>
);
};
export default MainContainer;
Here is how I've set up the Typescript models for this quiz app:
// src/models/Quiz.ts - not a TSX file, because there is no JSX used here. We'll store all TS models called models or similar.
export type Quiz = {
category: string;
type: string;
difficulty: string;
question: string;
correct_answer: string;
incorrect_answers: [
string
];
};
export type QuizResponse = {
response_code: number;
results: Quiz[];
}
And here's where a lot of the magic happens. The Card component uses Hooks in a different way than the MainComponent, because the Card component has more complexity. You'd be declaring variables for days follwing the previous page's pattern. Instead, just create an initialState
, similarly to how you would on a React Class component.
And that'll also make the state easier to reset when the user has finished the quiz. On button click, we just pass in the initial state to our setState({}) method.
// src/components/Card.tsx
import _ from 'lodash';
import React, { useState } from 'react';
import * as Models from './../models';
import './Card.scss';
interface Props {
quizQuestions?: Models.QuizResponse;
}
const Card = (props: Props) => {
const quizQuestions = _.get(props, 'quizQuestions.results', []);
// Here is another way to set state using React Hooks. This is a neater approach than setting them individually like you'll see
// in Main.tsx. This approach is great for larger states.
const initialState = {
currentIndex: 0,
score: 0,
showFinished: false,
answered: false,
selectedOption: '',
revealAnswer: '',
};
// These two variable below can be called anything, but we'll name them `state` and `setState` for convention.
const [state, setState] = useState(initialState);
// These are variables that we'll refer to throughout this component, so we'll set them on state here. If there are variables you
// are not referring to outside of the setState({}) funciton elsewhere, they dont need to be delcared here, but can be just set above.
const {
currentIndex,
score,
revealAnswer,
selectedOption,
} = state;
// A click event is typed as React.ChangeEvent<HTMLInputElement>
const handleChange = (e: React.ChangeEvent<HTMLInputElement>, correctAnswer: Models.Quiz): void => {
e.persist();
e.preventDefault();
const isCorrect: boolean = e.target.id.includes(correctAnswer.toString()) ? true : false;
const renderAnswer: string = isCorrect ? 'Correct!' : 'Sorry, wrong answer!';
setState({
...state,
selectedOption: e.target.id.toString(),
answered: isCorrect ? true : false,
revealAnswer: renderAnswer
});
if (currentIndex + 1 > quizQuestions.length) {
setState({ ...state, showFinished: true });
} else {
// delay for question auto-advance, to display 'Correct' or 'Incorrect' feedback
setTimeout(() => {
setState({ ...state, score: isCorrect ? score + 1 : score + 0, currentIndex: currentIndex + 1, revealAnswer: '' });
}, 2000);
}
};
// Below you could return a div, but since we already have an anchor div below, let's return a fragment.
const renderAnswer = (): React.ReactFragment => {
return (
<>{revealAnswer}</>
);
};
return (
quizQuestions && quizQuestions.length > 0 && (currentIndex < quizQuestions.length) ?
<div>
<h2>{quizQuestions[currentIndex].category}</h2>
<main className='Card'>
<h1>{_.unescape(quizQuestions[currentIndex].question)}</h1>
<div>Difficulty: {quizQuestions[currentIndex].difficulty}</div>
</main>
<section>
<div className='Answer'>{renderAnswer()}</div>
<form className='form'>
<div className='inputGroup' role='radiogroup'>
<label id='label' htmlFor='radioTrue' className='container'><input id='radioTrue' name='radio' type='radio' checked={selectedOption === 'True'} onChange={(e) => handleChange(e, quizQuestions[currentIndex].correct_answer)} />
True<span className='checkmark'></span></label>
</div>
<div className='inputGroup' role='radiogroup'>
<label id='label' htmlFor='radioFalse' className='container'><input id='radioFalse' name='radio' type='radio' checked={selectedOption === 'False'} onChange={(e) => handleChange(e, quizQuestions[currentIndex].correct_answer)} />
False<span className='checkmark'></span></label>
</div>
</form>
</section>
<footer className='Badge'>
Question {currentIndex + 1}/{quizQuestions.length}
</footer>
</div>
:
<div>
<main className='Card'>
<h3>
You scored {score} / {quizQuestions.length}
</h3>
<button className='Button' type='reset' onClick={() => setState(initialState)}>
Start Over
</button>
</main >
</div>
);
};
export default Card;
Lastly there some Sass files just to make things look good, but you dont need to follow those if you want to achieve your own aesthetic.
Checkout the full application on my Cat Perry Github page.
If you've found this helpful. Please Share it on Twitter and heart it as well. Happy coding, happy passing it on!
Top comments (3)
I think you can already start the project with typescript.
Create React App does come ready to convert to a Typescript app, so in one sense you're right. (Which means you can start changing relevant
.js
files to.ts
or.tsx
files, which will automatically add atsconfig.json
file and then during compile time your CRA will just tell you to then install typescript.)However, by using CRA's template for Typescript (
--template typescript
or--template=typescript
) in this step, 1) all relevant Create React App files are added into the app with the.ts
or.tsx
extension already for you, 2) all typescript packages and dependencies are added for you, and 3) atsconfig.json
file is also created for you. Which includes basic Typescript settings such as this. So CRA Templates do a little more setup for you.Thanks for the answer!