#はじめに
以前バズっていた↓の記事
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台痩せ型
↓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
#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.レイアウト関連のコンポーネント作成
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;
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 {
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
**を作成していきます。
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部分のみ実装できました。
##4-2.axiosを使用しデータを取得する
次にAPIを叩くために今回はaxiosを使うので、インストールしていきます。
$ yarn add axios
元記事ではaxiosではなく標準のfetchメソッドを使用していますが、練習も兼ねてaxiosを使用しました。
axiosの仕組みや詳しい説明は以下の記事がまとまっていてわかりやすいと思いました。
[axios] axios の導入と簡単な使い方
次にlibディレクトリの下に**axios.ts
**を作成します。
**${APIキー}
**の部分は後ほど書き換えます。
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
REACT_APP_MOVIE_API_KEY=11A11111
.envファイルは.gitignoreに追加しておきます。
---略---
.env #追加
export const apiKey = process.env.REACT_APP_MOVIE_API_KEY
これでどこからでもAPIキーを呼び出せるようになりました。
先程の**axios.ts
**でAPIキーをインポートして、URL部分を書き換えます。
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)
}
}
とりあえずこれでデータを取得することができました。ログを確認してみます。
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()
}
うまくいったようです。
また取得した映画情報のデータ構造を**console.log(result?.data)
**で確認してみます。
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
**で型定義も記載します。
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
**を作成したいと思います。
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)**
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
**で子コンポーネントに渡せるようにしたいと思います。
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コンポーネントの編集
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>
**です。
export const Search: React.FC = (props) => { /* 追加 */
return (
<GlobalState>
<SearchComponent />
</GlobalState>
)
}
この時点でステートにデータが渡ってきているか一応確認してみます。
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>
)
}
うまくいきました!ステータス200が返ってきているのがわかります。
#6.映画データの表示
最後にMovieコンポーネントを作成します。
元記事と殆どコードを変えていませんが、useContextを用いて取得したデータを表示するように実装しました。
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>
)
}
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 /> {/* 追加 */}
</>
)
}
#おわりに
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