【React】custom hookを使ってRrakuten APIを叩いてみる

こんばんは！スージーです。
業務で`custom hook`を使ったソースがあり、今まで使った事がなかったので使い方を練習してみました。

### 環境

- node v16.6.2
- React 17.0.2

### 使用したライブラリ

- axios
    - apiコールする時に使います
- dotenv-webpack
    - Rakuten APIで発行される`application_secret`を管理する為に使います
- urlencode
    - 検索窓に入力した日本語をエンコードする時に使います
- @mui/material
    - 一応の見た目を整える為に使います
- prop-types
    - TypeScriptを使わないのでreactの組み込みの型チェック機構を使ってみます


### 参考

- [公式custom hook](https://ja.reactjs.org/docs/hooks-custom.html)
- [公式propTypes](https://ja.reactjs.org/docs/typechecking-with-proptypes.html)
- [Reactのカスタムフック(Custom Hooks)を簡単な例で理解しておこう](https://reffect.co.jp/react/react-custom-hook)
- [Rakuten Developers](https://webservice.rakuten.co.jp/)
- [React Hooks でカスタムフックを作ってみよう](https://zenn.dev/izuchy/articles/5cec4c2318caff6490de)

### Rakuten Developersで`application_secret`を取得

[こちら](https://webservice.rakuten.co.jp/)で楽天市場などで使っているアカウントでログインして、`application_secret`を発行します。登録手順は割愛。色々なAPIが用意されていますが、今回使うのは`楽天商品検索API (version:2017-07-06)`です。パラメータなど色々[こちら](https://webservice.rakuten.co.jp/api/ichibaitemsearch/)に詳しく記載されています。リクエストは[こちら](https://webservice.rakuten.co.jp/explorer/api/IchibaItem/Search/)のテストフォームで送れるので参考にしてください。

### プロジェクトを作成

```terminal
YOUR_PROJECT_DIR $ npx create-react-app rakuten-search-app
$ cd rakuten-search-app
// ライブラリをinstall
$ npm install axios dotenv-webpack urlencode @mui/material
```

今回のプロジェクトのディレクト構造は以下のようにしました。

```
src
|_components
　|_ Home.jsx // 親コンポーネント
　|_ ItemSearch.jsx // 検索エリア
　|_Result.jsx // 検索結果表示
|_hooks
　|_ useFetch.js // custom hookのファイル名は頭にuseをつける
```

そして、完成イメージは以下のようになります

![画面収録 2021-09-25 0.32.14.mov.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/428016/0f8a2843-86af-1342-a935-2ccb6314255c.gif)

### custom hookを作成

custom hookは使い回す事ができる再利用可能な関数です。
custom hookを使うことでロジックとビューを切り離す事ができてコードの見通しが良くなります。

今回作成する`custom hook`の要件は以下のようにしました

```
submitボタン押下でイベント発火
検索フィールドからvalueを受け取る
データフェッチを開始
受け取ったvalueをエンコードする
axiosを使ってエンコードされた文字列をクエリパラメータにセット
Rakuten APIで用意されているエンドポイントへリクエスト
レスポンスをstateに格納
データフェッチ終了
最低限のエラーハンドリングとして、検索フィールドがnullの状態でsubmitされた時にはerrorを返す
```

```js
import { useState } from 'react';
import axios from 'axios';

// Rakuten APIで用意されているエンドポイント
const BASE_URL =
  'https://app.rakuten.co.jp/services/api/IchibaItem/Search/20170706?format=json';

const useFetchData = () => {
  // エラーを管理するstate
  const [error, setError] = useState({
    freeWord: false,
  });
  // ローディングする際に使うstate
  const [fetching, setFetching] = useState(false);
  // レスポンスを格納するstate
  const [result, setResult] = useState({});

  // submitボタン押下
  const handleSubmit = (value) => {
    const params = value.freeWord;

    if (params) {
      // ローディング開始
      setFetching(true);

      const encodedParams = encodeFreeWord(params);

      // apiコール
      axios
        .get(
          // envは後述
          `${BASE_URL}&keyword=${encodedParams}&page=1&applicationId=${process.env.REACT_APP_APPLICATION_ID}`
        )
        .then((response) => {
          // レスポンスデータを格納
          setResult(response.data);
          // ローディング終了
          setFetching(false);
        })
        .catch((error) => {
          console.log(error);
          setFetching(false);
        });
    } else {
      // nullの時はエラー
      console.log('検索条件を入力してください。');
      setError({
        freeWord: true,
      });
      setFetching(false);
    }
  };

  // エンコードする関数
  // 日本語 => エンコードされる
  // ex) アイフォン => %E3%82%A2%E3%82%A4%E3%83%95%E3%82%A9%E3%83%B3
  // 英語 => そのまま
  // ex) iphone => iphone
  const encodeFreeWord = (params) => {
    var urlEncode = require('urlencode');
    return urlEncode(params);
  };

  // データをオブジェクト型で返す
  return { error, setError, fetching, result, handleSubmit };
};
export default useFetchData;

```

`axios`を使ってapiを叩く際に`application_secret`をクエリパラメータで渡す必要があるので、`.env`ファイルと`webpack.config.js`ファイルをルートディレクトリに作成します

```
// .env
// 自身のapplication_secret
REACT_APP_APPLICATION_ID = '10***************29'
```

```js
// webpack.config.js
const Dotenv = require('dotenv-webpack');

module.exports = {
  plugins: [new Dotenv()],
};
```

これで`${process.env.REACT_APP_APPLICATION_ID}`として呼び出す事ができるになります
また`.env`は`gitignore`してgit管理下から外しましょう

もし`yarn start`でコケてエラーになったら

```
// .env
REACT_APP_APPLICATION_ID = '10***************29'
SKIP_PREFLIGHT_CHECK=true // 追加
```
とすればエラーは解消されるはず
[参考](https://reerishun.com/makerblog/?p=1003)


### 各コンポーネントを作成

```react
// App.jsx
import React from 'react';
import './App.css';
import Home from './components/Home';

function App() {
  return (
    <div className='App'>
      <Home />
    </div>
  );
}

export default App;
```
画面遷移しないので、`Home`コンポーネントをimportしているだけです

```react
// Home,jsx
import React, { useState } from 'react';
// custom hookをimport
import useFetchData from '../hooks/useFetch';

/*
style
*/
import Grid from '@mui/material/Grid';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';

/*
component
*/
import Result from './Result';
import ItemSearch from './ItemSearch';

const Home = () => {
  // custom hookでreturnされたobjectのvalue
  const { error, setError, fetching, result, handleSubmit } = useFetchData();
  // custom hookに渡すstate
  const [value, setValue] = useState({
    freeWord: '',
  });

  // 検索フィールドを監視
  const handleFreeWord = (e) => {
    // 文字を打ち始めたらstate初期化
    setError({
      freeWord: false,
    });
    // 入力文字を{ freeWord: '' }の形でstateで管理
    setValue({ [e.target.name]: e.target.value });
  };

  return (
    <>
      <ItemSearch
        value={value}
        error={error}
        handleFreeWord={handleFreeWord}
        handleSubmit={handleSubmit}
      />
      <Grid
        container
        direction='row'
        justifyContent='center'
        alignItems='center'
      >
        {/* フェッチ中はローディングがクルクルする */}
        {fetching ? (
          <Box m={10}>
            <CircularProgress />
          </Box>
        ) : (
          // フェッチが完了したらレスポンスデータを表示
          <Result result={result} />
        )}
      </Grid>
    </>
  );
};
export default Home;
```

```react
// ItemSearch.jsx
import React from 'react';
import PropTypes from 'prop-types';

/*
style
*/
import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';

const FREE_WORD = 'フリーワード';
const ERROR_FREE_WORD = 'フリーワードを入力してください。';

const ItemSearch = ({ value, error, handleFreeWord, handleSubmit }) => {
  return (
    <Box
      component='form'
      sx={{
        '& > :not(style)': { m: 1, width: '25ch' },
      }}
      noValidate
      autoComplete='off'
    >
      <TextField
        id='freeWord'
        label={FREE_WORD}
        variant='outlined'
        name='freeWord'
        value={value.freeWord}
        onChange={handleFreeWord}
        error={error.freeWord && true}
        helperText={error.freeWord && ERROR_FREE_WORD}
      />
      <br></br>
      <Button variant='outlined' onClick={() => handleSubmit(value)}>
        Submit
      </Button>
    </Box>
  );
};
export default ItemSearch;

// propTypesで型チェック
ItemSearch.propTypes = {
  value: PropTypes.shape({
    freeWord: PropTypes.string.isRequired,
  }),
  error: PropTypes.shape({
    freeWord: PropTypes.bool.isRequired,
  }),
  handleFreeWord: PropTypes.func.isRequired,
  handleSubmit: PropTypes.func.isRequired,
};

ItemSearch.defaultProps = {
  value: {
    freeWord: '',
  },
  error: {
    freeWord: false,
  },
  handleFreeWord: () => {},
  handleSubmit: () => {},
};
```

```react
// Result.jsx
import React from 'react';
import PropTypes from 'prop-types';

/*
 * style
 */
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardMedia from '@mui/material/CardMedia';
import Typography from '@mui/material/Typography';
import { CardActionArea } from '@mui/material';
import { Grid } from '@mui/material';

// 商品名を45文字目以降「...」にする
function convertSubString(string) {
  const name = string;
  if (name.length > 45) {
    const splitName = name.substring(0, 45);
    return splitName + '...';
  } else {
    return name;
  }
}

const Result = ({ result }) => {
  return (
    <>
      <Grid container spacing={2}>
        {result?.Items?.length >= 1
          ? result.Items.map((item, index) => (
              <Grid item xs={2} key={index}>
                <Card sx={{ maxWidth: 300, height: 320 }}>
                  <CardActionArea>
                    <CardMedia
                      component='img'
                      height='140'
                      image={item.Item.mediumImageUrls[0].imageUrl}
                      alt={item.Item.mediumImageUrls[0].imageUrl}
                    />
                    <CardContent>
                      <Typography gutterBottom variant='h6' component='div'>
                        {item.Item.itemPrice.toLocaleString()}円
                      </Typography>
                      <Typography variant='body2' color='text.secondary'>
                        {convertSubString(item.Item.itemName)}
                      </Typography>
                    </CardContent>
                  </CardActionArea>
                </Card>
              </Grid>
            ))
          : null}
      </Grid>
    </>
  );
};
export default Result;

Result.propTypes = {
  result: PropTypes.shape({
    Item: PropTypes.shape({
      mediumImageUrls: PropTypes.array.isRequired,
      itemPrice: PropTypes.number.isRequired,
      itemName: PropTypes.string.isRequired,
    }),
  }),
};

Result.defaultProps = {
  result: {},
};
```
Rakuten APIのAPIテストフォームを使ってオブジェクト構造を確認してみると以下のようになっているのでmapでループ処理をして展開してあげます

```json
{
  "Items": [
    {
      "Item": {
        "mediumImageUrls": [
          {
            "imageUrl": "https://thumbnail.image.rakuten.co.jp/@0_mall/icockaden/cabinet/iphonese/imgrc0112498389.jpg?_ex=128x128"
          },
          {
            "imageUrl": "https://thumbnail.image.rakuten.co.jp/@0_mall/icockaden/cabinet/iphonese/imgrc0112723567.jpg?_ex=128x128"
          },
          {
            "imageUrl": "https://thumbnail.image.rakuten.co.jp/@0_mall/icockaden/cabinet/iphonese/imgrc0112861775.jpg?_ex=128x128"
          }
        ],
        "pointRate": 1,
        "shopOfTheYearFlag": 0,
        "affiliateRate": 2,
        "shipOverseasFlag": 0,
        "asurakuFlag": 1,
        "endTime": "2022-12-07 16:36",
        "taxFlag": 0,
        "startTime": "2020-12-07 16:36",
        "itemCaption": "未使用品 iphone SE 2世代 64GB ホワイトMHGQ3J/A SIMフリー 【キャリア公式SIMロック解除品】送料無料 商品情報 iphone SE 2世代 64GB ホワイト SIMロック解除品 ネットワーク利用制限◯判定 SIMフリー 商品状態： 通電動作確認の為、開封品してあります。 メーカー保証期間残り半年以上1年間未満 Apple Care加入出来ません（加入可能期間過ぎている場合あり） 付属品： Lightning - USBケーブル 本体 マニュアル スペック アップル製品付き 　保証関しては 初期不良含みご自身でメーカー直接ご対応となりますので予めご了承ください、 当社にで修理交換などサポートは行っておりませんので予めご了承ください。未使用品 iphone SE 2世代 64GB MHGQ3J/A　ホワイト SIMフリー【SIMロック解除品】送料無料",
        "catchcopy": "",
        "tagIds": [
          1000873,
          1002851,
          1002891,
          1020074,
          1021160,
          1021164,
          1021165
        ],
        "smallImageUrls": [
          {
            "imageUrl": "https://thumbnail.image.rakuten.co.jp/@0_mall/icockaden/cabinet/iphonese/imgrc0112498389.jpg?_ex=64x64"
          },
          {
            "imageUrl": "https://thumbnail.image.rakuten.co.jp/@0_mall/icockaden/cabinet/iphonese/imgrc0112723567.jpg?_ex=64x64"
          },
          {
            "imageUrl": "https://thumbnail.image.rakuten.co.jp/@0_mall/icockaden/cabinet/iphonese/imgrc0112861775.jpg?_ex=64x64"
          }
        ],
        "asurakuClosingTime": "14:00",
        "imageFlag": 1,
        "availability": 1,
        "shopAffiliateUrl": "",
        "itemCode": "icockaden:10000179",
        "postageFlag": 0,
        "itemName": "【新品・未使用品】 iphone SE 第2世代 64GB ホワイト SIMフリー　MHGQ3J/A 送料無料 【即納】【あす楽】",
        "itemPrice": 33900,
        "pointRateEndTime": "",
        "shopCode": "icockaden",
        "affiliateUrl": "",
        "giftFlag": 0,
        "shopName": "家電問屋　楽天市場店",
        "reviewCount": 61,
        "asurakuArea": "群馬県/埼玉県/千葉県/東京都/神奈川県/山梨県/静岡県/愛知県/京都府/大阪府/兵庫県/茨城県/栃木県",
        "shopUrl": "https://www.rakuten.co.jp/icockaden/",
        "creditCardFlag": 1,
        "reviewAverage": 4.54,
        "shipOverseasArea": "",
        "genreId": "560202",
        "pointRateStartTime": "",
        "itemUrl": "https://item.rakuten.co.jp/icockaden/mhgq3ja/"
      }
    }
  ],
  "pageCount": 100,
  "TagInformation": [],
  "hits": 1,
  "last": 1,
  "count": 4175971,
  "page": 1,
  "carrier": 0,
  "GenreInformation": [],
  "first": 1
}
```
`hits`を1としているので1件のデータのみ取得していませんが、今回検索結果の一覧に表示しているのは

```
Item.mediumImageUrls[0].imageUrl
Item.itemPrice
Item.itemName
```
の3つです。

### 最後に
簡単な`custom hook`の実装ではありましたが、よくある`+ボタン`と`-ボタン`でincrement、decrementするサンプルよりapiリクエストを行うという、ちょっと踏み込んだ処理が書けました
業務のプロジェクトではゴリゴリと`custom hook`を使っているので引き続き勉強していきたいと思います

##### おわり