Pokemon アプリ

目指すゴールはフロントエンドライブラリ Reactopen in new window でひとつの Web アプリを作ること。無料で使える Pokemon APIopen in new windowSWRopen in new window でフェッチして多種多様なポケモンを見られるようにする

実際の成果物は Vercelopen in new window にデプロイを済ませており Hack Pokemonopen in new window をご確認いただければ幸いです

React プロジェクトを作成する

npx create-react-app <プロジェクト名> コマンドで React プロジェクトを作成する。 TypeScript で書くため --template typescript オプションを付ける。

npx create-react-app pokemon-app --template typescript
1

依存関係をインストールする

事前に Node.js 環境構築 が終わっていることを確認する

npm install
1

localhost で起動する

http://localhost:3000open in new window が Web ブラウザで開けば OK

# react-scripts start
npm run start
1
2

VSCode 開発環境を充実する

開発に当たって便利な設定にコード自動整形が存在する。コミット時にコードを自動整形するため [Code] - [Preferences] より [Settings] に入って setting.json を検索する

手始めに下記コードそのまま setting.json にコピー・ペーストする

{
    "editor.codeActionsOnSave": {
        "source.fixAll.eslint": true
    }
}
1
2
3
4
5

ファイルを保存する際に ESLint ルールを自動的に適用できる

コミット時にコードを自動整形する editor.codeActionsOnSave

かつての eslint.autoFixOnSave は廃止されたので注意が必要

.eslintrc.js の ESLint 設定を読み込む eslint.options

eslint.validate を書く必要があったが .eslintrc.js に適切な設定があれば TypeScript や Vue 、 HTML ファイルも検証する

下記以外のファイルで ESLint を使いたい時は引き続き eslint.validate 設定が必要です

  • TypeScript ... カスタムパーサとして @typescript-eslint/parser が設定されている
  • HTML ... プラグインの設定に eslint-plugin-html が存在する
  • Vue ... プラグインの設定に eslint-plugin-vue が存在する

ESLint 開発環境を構築する

create-react-app より始められた方は

create-react-app によって作成された React プロジェクトでは ESLint が含まれていないため、別途 ESLint 開発環境を構築する必要がある

一般的な React は eslint-plugin-react を、また TypeScript で書く場合は @typescript-eslint/eslint-plugin をインストールする必要がある

さらに Hooks をベースにしている場合は eslint-plugin-react-hooks もインストールした方が良い

npm install -D eslint eslint-plugin-react eslint-plugin-react-hooks @typescript-eslint/parser @typescript-eslint/parser
1

プロジェクトルートに .eslintrc.js という名目で作成する

touch .eslintrc.js
1

.eslintrc.js に下記コードをコピー・ペーストする

'use strict'

module.exports = {
    extends: [
        'plugin:react/recommended',
        'plugin:react-hooks/recommended'
    ],
    plugins: [
        '@typescript-eslint',
        'react',
        'react-hooks'
    ],
    root: true,
    env: { node: true, es6: true },
    parser: '@typescript-eslint/parser',
    parserOptions: {
        sourceType: 'module',
        ecmaFeatures: {
            jsx: true
        }
    },
    settings: {
        react: {
            version: 'detect'
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

.eslintrc.js を作成した後 setting.jsoneslint.options の configuration を追加する

{
    "eslint.options": {
        "configFile": "./.eslintrc.js"
    }
}
1
2
3
4
5

これで各プロジェクト、コミット時にコードを自動整形してくれる

ESLint コマンドを利用する

VSCode に任せず手動でも自動整形してくれるが eslint <対象ディレクトリ> --ext <拡張子> という書き方に則って ESLint コマンドを利用できる

# ESLint warnings / errors をリストアップする
eslint ./ --ext ts,tsx

# ESLint Warnings / errors を自動整形してくれる
eslint ./ --ext ts,tsx --fix
1
2
3
4
5

CI (Github Actions, Circle CI, etc) においてこの方法を使えるので、興味のある方は是非とも試して欲しい

API をフェッチする

Pokemon APIopen in new window を参考に curl コマンドでレスポンスの JSON を確認してみる

curl https://pokeapi.co/api/v2/pokemon    

{"count":1118,"next":"https://pokeapi.co/api/v2/pokemon?offset=20&limit=20","previous":null,"results":[{"name":"bulbasaur","url":"https://pokeapi.co/api/v2/pokemon/1/"},{"name":"ivysaur","url":"https://pokeapi.co/api/v2/pokemon/2/"},{"name":"venusaur","url":"https://pokeapi.co/api/v2/pokemon/3/"},{"name":"charmander","url":"https://pokeapi.co/api/v2/pokemon/4/"},{"name":"charmeleon","url":"https://pokeapi.co/api/v2/pokemon/5/"},{"name":"charizard","url":"https://pokeapi.co/api/v2/pokemon/6/"},{"name":"squirtle","url":"https://pokeapi.co/api/v2/pokemon/7/"},{"name":"wartortle","url":"https://pokeapi.co/api/v2/pokemon/8/"},{"name":"blastoise","url":"https://pokeapi.co/api/v2/pokemon/9/"},{"name":"caterpie","url":"https://pokeapi.co/api/v2/pokemon/10/"},{"name":"metapod","url":"https://pokeapi.co/api/v2/pokemon/11/"},{"name":"butterfree","url":"https://pokeapi.co/api/v2/pokemon/12/"},{"name":"weedle","url":"https://pokeapi.co/api/v2/pokemon/13/"},{"name":"kakuna","url":"https://pokeapi.co/api/v2/pokemon/14/"},{"name":"beedrill","url":"https://pokeapi.co/api/v2/pokemon/15/"},{"name":"pidgey","url":"https://pokeapi.co/api/v2/pokemon/16/"},{"name":"pidgeotto","url":"https://pokeapi.co/api/v2/pokemon/17/"},{"name":"pidgeot","url":"https://pokeapi.co/api/v2/pokemon/18/"},{"name":"rattata","url":"https://pokeapi.co/api/v2/pokemon/19/"},{"name":"raticate","url":"https://pokeapi.co/api/v2/pokemon/20/"}]}
1
2
3

今回は stale-while-revalidate と呼ばれるキャッシュ戦略に基づいた swropen in new window を利用する

swr について

RFC-5861open in new window で策定された効率的な HTTPCache-Control を実現するための戦略で、指定された期間に行われるキャッシュの再検証中は古いキャッシュを返す

バージョン swr@0.5.6open in new window を使う

npm install swr@0.5.6
1

swropen in new window の React カスタムフック useSWR を利用してデータをフェッチする

const { data, error } = useSWR(`https://pokeapi.co/api/v2/pokemon?limit=200&offset=200`)
1

このポイントとして読み込みに成功した場合はもちろん、読み込み中や読み込みに失敗した場合の挙動を容易に書けることが挙げられる

{/*読み込み中*/}
if (!data) return <div>Loading..</div>

{/*読み込みに失敗した*/}
if (error) return <div>Failed</div>

{/*読み込みに成功した*/}
return (
    <React.Fragment>
        {data.results.map((pokemon: { name: string; url: string }) => (
            <div key={pokemon.name}>
                {pokemon.name}
            </div>
        ))}
    </React.Fragment>
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

ポケモンの名前が表示されたことを確認できれば OK

コンポーネント設計

一つひとつのポケモンをその画像も合わせて表示したい

return (
    <React.Fragment>
        {data.results.map((pokemon: { name: string; url: string }) => (
            <div key={pokemon.name}>
                <a
                    href={`https://www.pokemon.com/us/pokedex/${props.pokemon.name}`}
                    target="_blank"
                    rel="noopener noreferrer"
                >
                    <img
                        alt={`${props.pokemon.name} image`}
                        src={`https://img.pokemondb.net/artwork/large/${props.pokemon.name}.jpg`}
                    />
                    <div>
                        {props.pokemon.name}
                    </div>
                </a>
            </div>
        ))}
    </React.Fragment>
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

一つのコンポーネントでも問題は無いが、それを肥大化させ過ぎてもコード全体の見通しが悪くなるばかり

export const Card = (props: { pokemon: { name: string; url: string } }) => {
    return (
        <a
            href={`https://www.pokemon.com/us/pokedex/${props.pokemon.name}`}
            target="_blank"
            rel="noopener noreferrer"
        >
            <img
                alt={`${props.pokemon.name} image`}
                src={`https://img.pokemondb.net/artwork/large/${props.pokemon.name}.jpg`}
            />
            <div>
                {props.pokemon.name}
            </div>
        </a>
    )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

作った Card コンポーネントを読み込む

import { Card } from './components/Card'
1

props に pokemon を渡す

return (
    <React.Fragment>
        {data.results.map((pokemon: { name: string; url: string }) => (
            <div key={pokemon.name}>
                <Card pokemon={pokemon} />
            </div>
        ))}
    </React.Fragment>
)
1
2
3
4
5
6
7
8
9

検索する

状態管理の手段に Hooks API のひとつ useState を利用して、親コンポーネント側 searchText という名目で検索ワードを保持する

const [searchText, setSearchText] = React.useState<string>('')

return (
    <Search text={searchText} setText={handleInputClick} />
)
1
2
3
4
5

Input フォームを作成する

今回 nekohack-uiopen in new window を利用したが、フォーム Input を作ることができれば何でも良い

import { NekoInput } from 'nekohack-ui'

export const Search = (props: { text: string; setText: Function }) => {
    return (
        <NekoInput
            value={props.text}
            placeholder="検索してください"
            onChange={props.setText}
        />
    )
}
1
2
3
4
5
6
7
8
9
10
11

作成した Search コンポーネントを読み込む

保持した searchText をリストの結果に反映させるため Hooks API のひとつ useMemo で再描画する

import { Card } from './Card'

export const CardList = (props: { data: Array<{ name: string; url: string }>; search: string }) => {
    const pokemonData = React.useMemo(() => {
        if (props.search) {
            return props.data.filter(
                (pokemon: { name: string; url: string }) =>
                    pokemon.name.indexOf(props.search) !== -1
            )
        }
        return props.data
    }, [props])

    return (
        <>
            {pokemonData?.map((pokemon: { name: string; url: string }) => (
                <div key={pokemon.name}>
                    <Card pokemon={pokemon} />
                </div>
            ))}
        </>
    )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

検索ワードと合わせて親コンポーネント側で呼び出せば良い

const { data, error } = useSWR(`https://pokeapi.co/api/v2/pokemon?limit=200&offset=200`)
const [searchText, setSearchText] = React.useState<string>('')

return (
    <CardList data={data.results} search={searchText} />
)
1
2
3
4
5
6

参照リポジトリ

補足

React を書く際なぜ関数コンポーネントを使うべきか、また今回扱わなかった useEffect についても書かせていただいた。

下記アンサー記事も合わせてチェックいただければ幸いです。