14
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-02-06

#はじめに
以前バズっていた↓の記事
2020年のフロントエンドマスターになりたければこの9プロジェクトを作れ

お恥ずかしながら、この記事を発見した頃には既に2021年になってしまっていました・・・
ですが内容的に勉強になりそうですし、2021年に始めても遅くないだろうということで、
9プロジェクトの一番はじめにある
Reactフックを使用して映画検索アプリを構築する方法
をやってみました。基本はこちらの記事を参考に進めていきます。

また今回は、昨今勢いが増しているTypeScriptをベースに実装しようと思います。

他にも練習も兼ねて
**- useContext

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

#環境

$ 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

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

#1.まずはプロジェクトの作成

$ 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
Reactアプリが立ち上がりました。

#2.APIキーの取得

こちらのアプリではOMDB APIを使用する必要があるので、記事にあるリンクより、APIキーを入手します。
記事では『私のものを使えます』という著者の記載もありましたが、無料なので私は登録してAPIキーを入手しました。

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

├── 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.レイアウト関連のコンポーネント作成

/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;
/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は記事にあるものをそのまま使ってしまおうと思います。

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**を作成していきます。

/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

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

$ yarn add axios

元記事ではaxiosではなく標準のfetchメソッドを使用していますが、練習も兼ねてaxiosを使用しました。
axiosの仕組みや詳しい説明は以下の記事がまとまっていてわかりやすいと思いました。
[axios] axios の導入と簡単な使い方

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

/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**を用いて管理するようにしました。

$ yarn add dotenv
.env
REACT_APP_MOVIE_API_KEY=11A11111

.envファイルは.gitignoreに追加しておきます。

.gitignore
---略---

.env #追加
/src/lib/entity.ts
export const apiKey = process.env.REACT_APP_MOVIE_API_KEY

これでどこからでもAPIキーを呼び出せるようになりました。
先程の**axios.ts**でAPIキーをインポートして、URL部分を書き換えます。

/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)
  }
}

とりあえずこれでデータを取得することができました。ログを確認してみます。

/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
うまくいったようです。
また取得した映画情報のデータ構造を**console.log(result?.data)**で確認してみます。

/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()
  }

するとデータは以下のようになっており、

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

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

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**で型定義も記載します。

/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**を作成したいと思います。

/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)**
/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**で子コンポーネントに渡せるようにしたいと思います。

/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コンポーネントの編集

/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>**です。

/src/components/Search.tsx
export const Search: React.FC = (props) => { /* 追加 */
  return (
    <GlobalState>
      <SearchComponent />
    </GlobalState>
  )
}

この時点でステートにデータが渡ってきているか一応確認してみます。

/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
うまくいきました!ステータス200が返ってきているのがわかります。

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

/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>    
  )
}
/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

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

また、
2020年のフロントエンドマスターになりたければこの9プロジェクトを作れ
こちらの記事は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

14
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?