『2020年のフロントエンドマスターになりたければこの9プロジェクトを作れ』を2021年にTypeScriptでやってみた

#はじめに
以前バズっていた↓の記事
*<strong>[2020年のフロントエンドマスターになりたければこの9プロジェクトを作れ](https://qiita.com/rana_kualu/items/915345b8f3f870cfe2aa)</strong>*

お恥ずかしながら、この記事を発見した頃には<font color="Blue">**既に2021年になってしまっていました・・・**</font>
ですが内容的に勉強になりそうですし、2021年に始めても遅くないだろうということで、
9プロジェクトの一番はじめにある
*<strong>[Reactフックを使用して映画検索アプリを構築する方法](https://www.freecodecamp.org/news/how-to-build-a-movie-search-app-using-react-hooks-24eb72ddfaf7/)</strong>*
をやってみました。基本はこちらの記事を参考に進めていきます。

また今回は、昨今勢いが増している<font color="Red">**TypeScriptをベースに実装しようと思います。**</font>

他にも練習も兼ねて
**- useContext
- useReducer
- axios**
を用いて元の記事のコードを書き換えてみたいと思います。
元の記事だとuseReducerは使われているので、そちらも参考にしております。

#環境
```bash
$ tsc -v
Version 4.1.2

$ node -v
v14.15.1

$ yarn -v
1.22.10

$ npx create-react-app --version
4.0.2
```

#使用技術
* TypeScript
* React
* create-react-app
* Yarn
* React Hooks
* useContext
* useReducer
* axios

#とりあえず自己紹介（?）
* 都内のweb系企業でReact,TypeScript,Ruby on Railsを使ってwebアプリ開発をしています
* 地方で5年半製薬企業の営業（MR）→ 2020年11月からエンジニアへジョブチェンジ
* 二郎系ラーメンが主食の170cm台痩せ型

#完成版
![movie-search.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/493982/e368e79c-d5b6-c42e-0b83-5f0f96de7ab4.gif)

↓Github
https://github.com/im05ttbbh/movie-search-app

#1.まずはプロジェクトの作成
```bash
$ npx create-react-app movie-search-app --template typescript
```
今回はTypeScriptベースということで、**`--template typescript`**をつけました。
アプリのディレクトリへ移動し、アプリを立ち上げます。

```
$ cd movie-search-app
$ yarn start
```
![スクリーンショット 2021-02-05 7.08.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/493982/43d90fba-2c3f-d2cf-256c-c379e74214ff.png)
Reactアプリが立ち上がりました。

#2.APIキーの取得

こちらのアプリでは[OMDB API](http://www.omdbapi.com/)を使用する必要があるので、[記事にあるリンク](http://www.omdbapi.com/apikey.aspx?__EVENTTARGET=freeAcct&__EVENTARGUMENT=&__LASTFOCUS=&__VIEWSTATE=%2FwEPDwUKLTIwNDY4MTIzNQ9kFgYCAQ9kFgICBw8WAh4HVmlzaWJsZWhkAgIPFgIfAGhkAgMPFgIfAGhkGAEFHl9fQ29udHJvbHNSZXF1aXJlUG9zdEJhY2tLZXlfXxYDBQtwYXRyZW9uQWNjdAUIZnJlZUFjY3QFCGZyZWVBY2N0oCxKYG7xaZwy2ktIrVmWGdWzxj%2FDhHQaAqqFYTiRTDE%3D&__VIEWSTATEGENERATOR=5E550F58&__EVENTVALIDATION=%2FwEdAAU%2BO86JjTqdg0yhuGR2tBukmSzhXfnlWWVdWIamVouVTzfZJuQDpLVS6HZFWq5fYpioiDjxFjSdCQfbG0SWduXFd8BcWGH1ot0k0SO7CfuulHLL4j%2B3qCcW3ReXhfb4KKsSs3zlQ%2B48KY6Qzm7wzZbR&at=freeAcct&Email=)より、APIキーを入手します。
記事では『私のものを使えます』という著者の記載もありましたが、無料なので私は登録してAPIキーを入手しました。

#3.ファイル作成
準備は整ったので、順次ファイルを作成していきます。
最終的には以下のディレクトリ構成となりました。
（**`srcディレクトリ`**以外はあまりいじらないので、publicディレクトリの中身等は省略しています）

```bash
├── package.json
├── public
├── src
│   ├── App.css
│   ├── components
│   │   ├── App.tsx
│   │   ├── GlobalState.tsx
│   │   ├── Header.tsx
│   │   ├── Movie.tsx
│   │   └── Search.tsx
│   ├── index.css
│   ├── index.tsx
│   └── lib
│       ├── axios.ts
│       └── entity.ts
├── .env
├── .gitignore
├── tsconfig.json
└── yarn.lock
```
#4.実装 〜axiosでAPIを叩いてデータの取得まで〜
##4-1.レイアウト関連のコンポーネント作成
```react:/src/components/App.tsx
import React from 'react';
import '../App.css';
import { Header } from './Header';
import { Search } from './Search';

const App: React.FC = () => {
  return (
    <div className="App">
      <Header text="HOOKED" />
      <Search />
    </div>
  );
}

export default App;
```
```react:/src/components/Header.tsx
import React from 'react';

type HeaderProps = {
  text: string
}

export const Header: React.FC<HeaderProps> = (props) => {
  return (
    <header className="App-header">
      <h2>{props.text}</h2>
    </header>
  )
}
```
ここまでは特筆すべきことはありません。
CSSは記事にあるものをそのまま使ってしまおうと思います。

```react:App.css
.App {
  text-align: center;
}

.App-header {
  background-color: #282c34;
  height: 70px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
  padding: 20px;
  cursor: pointer;
}

.spinner {
  height: 80px;
  margin: auto;
}

.App-intro {
  font-size: large;
}

/* new css for movie component */

* {
  box-sizing: border-box;
}

.movies {
  display: flex;
  flex-wrap: wrap;
  flex-direction: row;
}

.App-header h2 {
  margin: 0;
}

.add-movies {
  text-align: center;
}

.add-movies button {
  font-size: 16px;
  padding: 8px;
  margin: 0 10px 30px 10px;
}

.movie {
  padding: 5px 25px 10px 25px;
  max-width: 25%;
}

.errorMessage {
  margin: auto;
  font-weight: bold;
  color: rgb(161, 15, 15);
}

.search {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  justify-content: center;
  margin-top: 10px;
}


input[type="submit"] {
  padding: 5px;
  background-color: transparent;
  color: black;
  border: 1px solid black;
  width: 80px;
  margin-left: 5px;
  cursor: pointer;
}


input[type="submit"]:hover {
  background-color: #282c34;
  color: antiquewhite;
}


.search > input[type="text"]{
  width: 40%;
  min-width: 170px;
}

@media screen and (min-width: 694px) and (max-width: 915px) {
  .movie {
      max-width: 33%;
  }
}

@media screen and (min-width: 652px) and (max-width: 693px) {
  .movie {
      max-width: 50%;
  }
}


@media screen and (max-width: 651px) {
  .movie {
      max-width: 100%;
      margin: auto;
  }
}
```
次に**`Search.tsx`**を作成していきます。

```tsx:/src/components/Search.tsx
import React, { useState } from 'react';

export const Search: React.FC = () => {
  const [searchValue, setSearchValue] = useState<string>("")

  const handleSearchValue = (e: React.ChangeEvent<HTMLInputElement>) => {
    setSearchValue(e.target.value)
  }

  const resetInputField = () => {
    setSearchValue("")
  }

  const callSearchFunction = (e: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
    e.preventDefault()
    resetInputField()
  }

  return (
    <>
      <p className="App-intro">Search movies!!!!!</p>
      <form className="search">
        <input
          value={searchValue}
          onChange={(e) => handleSearchValue(e)}
          type="text"
        />
        <input onClick={(e) => callSearchFunction(e)} type="submit" value="SEARCH" />
      </form>
    </>
  )
}
```
これで一先ずフォームとヘッダーのUI部分のみ実装できました。
![スクリーンショット 2021-02-05 8.07.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/493982/41216a2f-d82c-7d71-6a4b-5c4d6a2fdf99.png)

##4-2.axiosを使用しデータを取得する
次にAPIを叩くために今回はaxiosを使うので、インストールしていきます。

```bash
$ yarn add axios
```
元記事ではaxiosではなく標準のfetchメソッドを使用していますが、練習も兼ねてaxiosを使用しました。
axiosの仕組みや詳しい説明は以下の記事がまとまっていてわかりやすいと思いました。
*[[axios] axios の導入と簡単な使い方](https://qiita.com/ksh-fthr/items/2daaaf3a15c4c11956e9)*

次にlibディレクトリの下に**`axios.ts`**を作成します。
**`${APIキー}`**の部分は後ほど書き換えます。

```typescript:/src/lib/axios.ts
import axios from "axios";

export const fetchMovie = async (searchValue?: string) => {  
  try {
    return await axios.get(`http://www.omdbapi.com/?s=${searchValue}&apikey=${APIキー}`)
  } catch (e) {
    console.error(e)
  }
}
```

ここでfetchMovieメソッドをexportしておいて、他のコンポーネントから使えるようにします。

またAPIキーは直書きではなく、**`dotenv`**を用いて管理するようにしました。

```bash
$ yarn add dotenv
```
```text:.env
REACT_APP_MOVIE_API_KEY=11A11111
```
.envファイルは.gitignoreに追加しておきます。

```text:.gitignore
---略---

.env #追加
```
```typescript:/src/lib/entity.ts
export const apiKey = process.env.REACT_APP_MOVIE_API_KEY
```
これでどこからでもAPIキーを呼び出せるようになりました。
先程の**`axios.ts`**でAPIキーをインポートして、URL部分を書き換えます。

```typescript:/src/lib/axios.ts
import axios from "axios";
import { apiKey } from "./entity"; /* 追加 */

export const fetchMovie = async (searchValue: string) => {  
  try {
    return await axios.get(`http://www.omdbapi.com/?s=${searchValue}&apikey=${apiKey}`) /* 修正 */
  } catch (e) {
    console.error(e)
  }
}
```
とりあえずこれでデータを取得することができました。ログを確認してみます。

```tsx:/src/components/Search.tsx
import React, { useState } from 'react';
import { fetchMovie } from '../lib/axios';

export const Search: React.FC = () => {
  const [searchValue, setSearchValue] = useState<string>("")

  ---略---

  const callSearchFunction = (e: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
    e.preventDefault()
    fetchMovie(searchValue).then(result => { /* 追加 */
      console.log(result)
    })
    resetInputField()
  }
```
![スクリーンショット 2021-02-06 8.34.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/493982/d0b972ed-9ef3-a65c-cafb-b7cb7646ad65.png)
うまくいったようです。
また取得した映画情報のデータ構造を**`console.log(result?.data)`**で確認してみます。

```tsx:/src/components/Search.tsx
import React, { useState } from 'react';
import { fetchMovie } from '../lib/axios';

export const Search: React.FC = () => {
  const [searchValue, setSearchValue] = useState<string>("")

  ---略---

  const callSearchFunction = (e: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
    e.preventDefault()
    fetchMovie(searchValue).then(result => {
      console.log(result?.data)  /* 修正 */
    })
    resetInputField()
  }
```
するとデータは以下のようになっており、

```text
{
  Response: "True"
  Search: (10) [{…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}]
  totalResults: "27"
}

```
Searchオブジェクトの中身の構造は↓でした。

```text
Search: {
  Poster: "https://m.media- amazon.com/images/M/MV5BODllNWE0MmEtYjUwZi00ZjY3LThmNmQtZjZlMjI2YTZjYmQ0XkEyXkFqcGdeQXVyNTc1NTQxODI@._V1_SX300.jpg"
  Title: "Léon: The Professional"
  Type: "movie"
  Year: "1994"
  imdbID: "tt0110413"
}
```
これを元に、**`entity.ts`**で型定義も記載します。

```typescript:/src/lib/entity.ts
export type MovieObj = { /* 追加 */
  Poster: string
  Title: string
  Type: string
  Year: string
  imdbID: string
}

export const apiKey = process.env.REACT_APP_MOVIE_API_KEY
```
#5.実装 〜useReducerとuseContextを用いたステート管理〜
今回はuseReducerを使用し、useContextと組み合わせるので**`GlobalState.tsx`**を作成したいと思います。

```tsx:/src/components/GlobalState.tsx
import React, { useReducer } from "react";

export const GlobalState: React.FC = (props) => {
  const [state, dispatch] = useReducer(reducer, initialState)

  return (
    <StateContext.Provider value={{ state, dispatch }}>
      {props.children}
    </StateContext.Provider>
  )
}
```
大枠としてはContext.Providerを用いて、
**`<StateContext.Provider>`**でラップしたコンポーネントにreducerの**`state, dispatch`**を渡せるようにします。
それではまずreducerの方から定義していきます。
##5-1.reducerの実装
アクションのタイプは元記事の通り以下の3つを定義しました。
- **映画情報取得のリクエスト（SEARCH_MOVIES_REQUEST）
- 映画情報取得の成功（SEARCH_MOVIES_SUCCESS）
- 映画情報取得の失敗（SEARCH_MOVIES_FAILURE）**

```tsx:/src/components/GlobalState.tsx
import React, { useReducer } from "react";
import { MovieObj } from "../lib/entity";

const initialState = {
  loading: false,
  movies: [],
  errorMessage: ""
}

type State = {
  loading: boolean
  movies: MovieObj[]
  errorMessage: string
}

export enum ActionType {
  SEARCH_MOVIES_REQUEST = "SEARCH_MOVIES_REQUEST",
  SEARCH_MOVIES_SUCCESS = "SEARCH_MOVIES_SUCCESS",
  SEARCH_MOVIES_FAILURE = "SEARCH_MOVIES_FAILURE"
}

type RequestSearchType = { type: ActionType.SEARCH_MOVIES_REQUEST, loading: boolean }
type SuccessSearchType = { type: ActionType.SEARCH_MOVIES_SUCCESS, payload: MovieObj[] }
type FailureSearchType = { type: ActionType.SEARCH_MOVIES_FAILURE, errorMessage: string }

type SearchActionType = RequestSearchType | SuccessSearchType | FailureSearchType

const reducer = (state: State, action: SearchActionType): State => {
  switch (action.type) {
    case ActionType.SEARCH_MOVIES_REQUEST:
      return {
        ...state,
        loading: true,
      }
    case ActionType.SEARCH_MOVIES_SUCCESS:
      return {
        ...state,
        loading: false,
        movies: action.payload
      }   
    case ActionType.SEARCH_MOVIES_FAILURE:
      return {
        ...state,
        loading: false,
        errorMessage: action.errorMessage,
      }
    default:
      return state 
  }
}

export const GlobalState: React.FC = (props) => {
  const [state, dispatch] = useReducer(reducer, initialState)    

  return (
    <>
    </>
  )
}
```

##5-2.contextの実装
reducerが実装できたので、ステートのデータを**`Context.Provider`**で子コンポーネントに渡せるようにしたいと思います。

```tsx:/src/conponents/GlobalState.tsx
import React, { useReducer } from "react";
import { MovieObj } from '../lib/entity';

const initialState = {
  loading: false,
  movies: [],
  errorMessage: ""
}

type State = {
  loading: boolean
  movies: MovieObj[]
  errorMessage: string
}

type StateContextType = { /* 追加 */
  state: State
  dispatch: React.Dispatch<SearchActionType>
}

export const StateContext = React.createContext({} as StateContextType) /* 追加 */

export enum ActionType {
  SEARCH_MOVIES_REQUEST = "SEARCH_MOVIES_REQUEST",
  SEARCH_MOVIES_SUCCESS = "SEARCH_MOVIES_SUCCESS",
  SEARCH_MOVIES_FAILURE = "SEARCH_MOVIES_FAILURE"
}

type RequestSearchType = { type: ActionType.SEARCH_MOVIES_REQUEST, loading: boolean }
type SuccessSearchType = { type: ActionType.SEARCH_MOVIES_SUCCESS, payload: MovieObj[] }
type FailureSearchType = { type: ActionType.SEARCH_MOVIES_FAILURE, errorMessage: string }

type SearchActionType = RequestSearchType | SuccessSearchType | FailureSearchType

const reducer = (state: State, action: SearchActionType): State => {
  switch (action.type) {
    case ActionType.SEARCH_MOVIES_REQUEST:
      return {
          ...state,
          loading: true,
      }
    case ActionType.SEARCH_MOVIES_SUCCESS:
      return {
          ...state,
          loading: false,
          movies: action.payload
      }   
    case ActionType.SEARCH_MOVIES_FAILURE:
      return {
          ...state,
          loading: false,
          errorMessage: action.errorMessage,
      }
    default:
      return state 
  }
}

export const GlobalState: React.FC = (props) => {
  const [state, dispatch] = useReducer(reducer, initialState)    

  return ( /* 追加 */
    <StateContext.Provider value={{ state, dispatch }}>
      {props.children}
    </StateContext.Provider>
  )
}
```
reducerとcontextの記載は以上です。

##5-3.Searchコンポーネントの編集

```tsx:/src/components/Search.tsx
import React, { useContext, useState } from 'react';
import { fetchMovie } from '../lib/axios';
import { StateContext, ActionType, GlobalState } from './GlobalState'; /* 追加 */

const SearchComponent: React.FC = () => { /* コンポーネント名変更 */
  const [searchValue, setSearchValue] = useState<string>("")
  const { dispatch } = useContext(StateContext) /* 追加 */

  const handleSearchValue = (e: React.ChangeEvent<HTMLInputElement>) => setSearchValue(e.target.value)

  const resetInputField = () => setSearchValue("")

  const callSearchFunction = (e: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
    e.preventDefault()
    dispatch({ /* 追加 */
        type: ActionType.SEARCH_MOVIES_REQUEST,
        loading: true
    })

    fetchMovie(searchValue).then(result => { /* 追加 */
      if (result?.data.Response === "True") {
        dispatch({
          type: ActionType.SEARCH_MOVIES_SUCCESS,
          payload: result.data.Search
        })
      } else {
        dispatch({
          type: ActionType.SEARCH_MOVIES_FAILURE,
          errorMessage: result?.data.Error,
        })
      }
    })
    resetInputField()
  }

  return (
    <>
      <p className="App-intro">Search movies!!!!!</p>
      <form className="search">
        <input
          value={searchValue}
          onChange={(e) => handleSearchValue(e)}
          type="text"
        />
        <input onClick={(e) => callSearchFunction(e)} type="submit" value="SEARCH" />
      </form>
    </>
  )
}


export const Search: React.FC = () => { /* 追加 */
  return (
    <GlobalState>
      <SearchComponent />
    </GlobalState>
  )
}
```
useContextを用いてreducerを使用するために、**`<GlobalState>`**でラップしたことに伴い、
**`Search ⇛ SearchComponent`**に名前を変更しました。
実際にexportするのはラップしている側の**`<Search>`**です。

```tsx:/src/components/Search.tsx
export const Search: React.FC = (props) => { /* 追加 */
  return (
    <GlobalState>
      <SearchComponent />
    </GlobalState>
  )
}
```
この時点でステートにデータが渡ってきているか一応確認してみます。

```tsx:/src/components/GlobalState.tsx
export const GlobalState: React.FC = (props) => {
  const [state, dispatch] = useReducer(reducer, initialState)

  console.log(state) /* 追加 */

  return (
    <StateContext.Provider value={{ state, dispatch }}>
      {props.children}
    </StateContext.Provider>
  )
}
```
![スクリーンショット 2021-02-06 8.34.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/493982/4d0d2d2d-803c-629e-3029-6d0a8326c64d.png)
うまくいきました！ステータス200が返ってきているのがわかります。

#6.映画データの表示
最後にMovieコンポーネントを作成します。
元記事と殆どコードを変えていませんが、useContextを用いて取得したデータを表示するように実装しました。

```tsx:/src/components/Movie.tsx
import React, { useContext } from 'react'
import { StateContext } from './GlobalState'

const DEFAULT_PLACEHOLDER_IMAGE = 
"https://m.media-amazon.com/images/M/MV5BMTczNTI2ODUwOF5BMl5BanBnXkFtZTcwMTU0NTIzMw@@._V1_SX300.jpg"

export const Movie: React.FC = () => {
  const { state } = useContext(StateContext)
  const { loading, errorMessage, movies } = state

  return (
    <div className="movies">
      {loading ? (
        <div style={{ margin: "auto" }}>loading...</div>
      ) : (
        errorMessage ? <div className="errorMessage">{errorMessage}</div>
        :
        movies && movies.map((movie, index) => {
          return (
            <div className="movie" key={`${index}-${movie.Title}`}>
              <h2>{movie.Title}</h2>
              <div>
                <img
                  width="200"
                  alt={`The movie titled: ${movie.Title}`}
                  src={movie.Poster === "N/A" ? DEFAULT_PLACEHOLDER_IMAGE : movie.Poster}
                />
              </div>
              <p>{movie.Year}</p>
            </div>
          )
        })
      )}
    </div>    
  )
}
```
```tsx:/src/components/Search.tsx
const SearchComponent: React.FC = () => {

  ---略---

  return (
    <>
      <p className="App-intro">Search movies!!!!!</p>
      <form className="search">
        <input
          value={searchValue}
          onChange={(e) => handleSearchValue(e)}
          type="text"
        />
        <input onClick={(e) => callSearchFunction(e)} type="submit" value="SEARCH" />
      </form>
      <Movie /> {/* 追加 */}
    </>
  )
}
```
これで実装は以上です。
![スクリーンショット 2021-02-06 10.37.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/493982/795eb9b4-f678-f63c-7ece-e5df161bcaa5.png)

#おわりに
**`useContext`**はあまり使ったことがなかったですが、いわゆる**propsのバケツリレー**を防止するには非常に有用だと実感しました。
今回の規模のアプリであれば**`useState`**を使用してpropsでデータを表示させていけば十分だったかと思いますが、もう少し規模が大きくなるとそのありがたみが一層感じられるのだと思います。

また、
*<strong>[2020年のフロントエンドマスターになりたければこの9プロジェクトを作れ](https://qiita.com/rana_kualu/items/915345b8f3f870cfe2aa)</strong>*
こちらの記事は1/9しか出来ていないので(笑)、『9/9やりきった！』という状態になるよう引き続き精進していきたいです。

#参考
https://ja.reactjs.org/docs/hooks-reference.html
https://www.freecodecamp.org/news/how-to-build-a-movie-search-app-using-react-hooks-24eb72ddfaf7/
https://www.to-r.net/media/react-tutorial-hooks-usecontext/
https://www.webopixel.net/javascript/1647.html