こんばんは!スージーです。
業務でcustom hook
を使ったソースがあり、今まで使った事がなかったので使い方を練習してみました。
環境
- node v16.6.2
- React 17.0.2
使用したライブラリ
- axios
- apiコールする時に使います
- dotenv-webpack
- Rakuten APIで発行される
application_secret
を管理する為に使います
- Rakuten APIで発行される
- urlencode
- 検索窓に入力した日本語をエンコードする時に使います
- @mui/material
- 一応の見た目を整える為に使います
- prop-types
- TypeScriptを使わないのでreactの組み込みの型チェック機構を使ってみます
参考
- 公式custom hook
- 公式propTypes
- Reactのカスタムフック(Custom Hooks)を簡単な例で理解しておこう
- Rakuten Developers
- React Hooks でカスタムフックを作ってみよう
Rakuten Developersでapplication_secret
を取得
こちらで楽天市場などで使っているアカウントでログインして、application_secret
を発行します。登録手順は割愛。色々なAPIが用意されていますが、今回使うのは楽天商品検索API (version:2017-07-06)
です。パラメータなど色々こちらに詳しく記載されています。リクエストはこちらのテストフォームで送れるので参考にしてください。
プロジェクトを作成
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をつける
そして、完成イメージは以下のようになります
custom hookを作成
custom hookは使い回す事ができる再利用可能な関数です。
custom hookを使うことでロジックとビューを切り離す事ができてコードの見通しが良くなります。
今回作成するcustom hook
の要件は以下のようにしました
submitボタン押下でイベント発火
検索フィールドからvalueを受け取る
データフェッチを開始
受け取ったvalueをエンコードする
axiosを使ってエンコードされた文字列をクエリパラメータにセット
Rakuten APIで用意されているエンドポイントへリクエスト
レスポンスをstateに格納
データフェッチ終了
最低限のエラーハンドリングとして、検索フィールドがnullの状態でsubmitされた時にはerrorを返す
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'
// 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 // 追加
とすればエラーは解消されるはず
参考
各コンポーネントを作成
// 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しているだけです
// 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;
// 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: () => {},
};
// 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でループ処理をして展開してあげます
{
"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
を使っているので引き続き勉強していきたいと思います