React.jsの理解を深めたかったので、楽天トラベル空室検索API
を使った簡単なアプリケーションを作成しました。
今回は以下を導入しています。
- Material-UI
- axios
- Redux
- redux-thunk
- styled-components
udemyと参考書を読んできて「こんな感じかな?」で作ったので、これが正解という訳ではありません、悪しからず。
【出来上がり】
こんな感じ
もう少し見栄えを良くすればよかったのですが、とにかくアウトプットしたかったので次のアプリ作成に入ってしまいました。
作成したのはトップページとホテル詳細ページだけです。
ディレクトリはこんな感じ。
root/
├ src/
│ └ css/
│ └ style.scss
│ └ js/
│ └ index.js
│ └ App.js
│ └ Home.js
│ └ HotelDetail.js
│ └ actions/
│ └ index.js
│ └ types.js
│ └ reducers/
│ └ index.js
│ └ hotelReducer.js
│ └ hotelsReducer.js
│ └ libs/
│ └ RakutenTravelApi.js
│ └ GoogleGeocodeApi.js
│ └ materialui/
│ └ theme.js
└ dist/
└ css/
└ style.css
└ js/
└ bundle.js
見てなんとなくわかると思いますが、コンポーネントの分け方はざっくりこんな感じです。
index.js
ミドルウェア、Provider、react-routerを実装しています。
書き方はUdemyで教わったまんまです。
Reduxのextention dev toolを使いたかったので、これも実装しました。
テーマも緑とオレンジに色を変えたかったので、MuiThemeProvider
を使っています。
import '../css/style.scss'
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import { createStore, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import reducers from './reducers'
import { MuiThemeProvider } from '@material-ui/core/styles'
import { theme } from './materialui/theme'
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
const store = createStore(
reducers,
composeEnhancers(applyMiddleware(thunk))
)
ReactDOM.render(
<MuiThemeProvider theme={theme}>
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
</MuiThemeProvider>,
document.querySelector('#app')
)
App.js
ホットリロード使いたかったのでApp.jsの方に実装しています。
react-routerのRoute
、Switch
を使ってトップページと詳細ページを切り替えるようにしました。
import React from 'react'
import { Route, Switch } from 'react-router-dom'
import { hot } from 'react-hot-loader/root'
import Home from './Home'
import HotelDetail from './HotelDetail'
import Header from './components/Header'
const App = () => (
<div>
<Header />
<Switch>
<Route exact path='/' component={Home} />
<Route path='/hotels/:id' component={HotelDetail} />
</Switch>
</div>
)
export default hot(App)
Home.js
検索フォームコンポーネント(SearchForm.js)とホテル一覧コンポーネント(HotelList.js)を読み込んでいます。
ホテル一覧コンポーネントは描画に徹することにして、Home.jsでreduxで管理しているホテル一覧情報(hotels
)を取得し渡しています。
検索フォームで入力されたチェックイン&チェックアウトから、ホテル一覧を取得したかったので、このコンポーネント内のstateでcheckInDate
、checkOutDate
を用意しています。さらにHotelList.jsでthis.props.checkInDate
と使えるよう渡しています。
import React from 'react'
import { connect } from 'react-redux'
import SearchForm from './components/SearchForm'
import HotelList from './components/HotelList'
import styled from 'styled-components'
import { withStyles } from '@material-ui/core/styles'
import Grid from '@material-ui/core/Grid'
class Home extends React.Component {
constructor(props) {
super(props)
this.state = {
checkInDate: '',
checkOutDate: ''
}
this.getSearchInfo = this.getSearchInfo.bind(this)
}
getSearchInfo(checkIn, checkOut) {
this.setState({
checkInDate: checkIn,
checkOutDate: checkOut
})
}
render() {
const { classes } = this.props
return (
<div>
<SearchFormWrap>
<SearchForm getSearchInfo={this.getSearchInfo} />
</SearchFormWrap>
<Grid container spacing={16} className={classes.wrapper}>
<HotelList hotels={this.props.hotels}
checkInDate={this.state.checkInDate}
checkOutDate={this.state.checkOutDate} />
</Grid>
</div>
)
}
}
const mapStateToProps = state => {
return { hotels: state.hotels }
}
const styles = theme => ({
wrapper: {
[theme.breakpoints.up("lg")]: {
maxWidth: 1170,
marginLeft: 'auto',
marginRight: 'auto'
}
}
})
export default connect(mapStateToProps)(withStyles(styles)(Home))
const SearchFormWrap = styled.div`
max-width: 600px;
margin: 3rem auto;
`
SearchForm.js
参考書でよくあるonChangeとonSubmitを実装した感じです。
Datepickerで取得したデータを整形するのに、momentを使っています。
流れは
検索したい地名を入力 → GoogleGeocodeApi.jsを呼び出して地名から緯度経度を取得 → mapDispatchToPropsのgetHotels
関数呼び出して楽天トラベル空室検索APIからデータを取得 → reducerで用意しているhotels
に入る。
もっと上手い記述があったのでは?と心の中で思いつつ、特に突っ込まれなかったのでこのままにしました。
import React from 'react'
import { connect } from 'react-redux'
import { getHotels } from '../actions'
import moment from 'moment'
import 'moment/locale/ja'
moment.locale('ja')
import MomentUtils from '@date-io/moment'
import { MuiPickersUtilsProvider, InlineDatePicker } from 'material-ui-pickers'
import geocodeClient from '../libs/GoogleGeocodeApi'
import styled from 'styled-components'
import Typography from '@material-ui/core/Typography'
import TextField from '@material-ui/core/TextField'
import Button from '@material-ui/core/Button'
class SearchForm extends React.Component {
constructor(props) {
super(props)
this.state = {
place: '',
checkInDatepicker: null,
checkOutDatepicker: null,
checkInDate: '',
checkOutDate: ''
}
this.handleSubmit = this.handleSubmit.bind(this)
this.handleChangePlace = this.handleChangePlace.bind(this)
this.handleChangeCheckIn = this.handleChangeCheckIn.bind(this)
this.handleChangeCheckOut = this.handleChangeCheckOut.bind(this)
}
handleChangePlace(e) {
this.setState({ place: e.target.value })
}
handleChangeCheckIn(date) {
let changeFormatDate = moment(date).format('YYYY-MM-DD')
this.setState({
checkInDatepicker: date,
checkInDate: changeFormatDate
})
}
handleChangeCheckOut(date) {
let changeFormatDate = moment(date).format('YYYY-MM-DD')
this.setState({
checkOutDatepicker: date,
checkOutDate: changeFormatDate
})
}
handleSubmit(e) {
const { place, checkInDate, checkOutDate } = this.state
if(place && checkInDate && checkOutDate) {
geocodeClient(this.state.place).then(response => {
const json = JSON.parse(response)
const { lat, lng } = json.results[0].geometry.location
return {
latitude: lat,
longitude: lng
}
}).then(({ latitude, longitude }) => {
this.props.getHotels({ checkInDate, checkOutDate, latitude, longitude })
this.props.getSearchInfo(checkInDate, checkOutDate)
}).catch(error => {
console.error(error)
})
} else {
alert('未入力があります')
}
e.preventDefault()
}
}
export default connect(null, { getHotels })(SearchForm)
上記コード一部割愛しています。
HotelList.js
渡ってきた結果を描画するだけのコンポーネントです。この辺りはチュートリアルや参考書等でもお馴染みになっています。
特に難しいことはやっていないです。
import React, { Fragment } from 'react'
import { Link } from 'react-router-dom'
import styled from 'styled-components'
import Grid from '@material-ui/core/Grid'
import Card from '@material-ui/core/Card'
import CardActions from '@material-ui/core/CardActions'
import CardContent from '@material-ui/core/CardContent'
import CardMedia from '@material-ui/core/CardMedia'
import Button from '@material-ui/core/Button'
import Typography from '@material-ui/core/Typography'
import { withTheme } from '@material-ui/core/styles';
class HotelList extends React.Component {
render() {
const { hotels, checkInDate, checkOutDate } = this.props
return (
<Fragment>
{hotels.map(hotel => {
const {
hotelImageUrl,
hotelMinCharge,
hotelName,
hotelSpecial,
reviewAverage,
hotelNo
} = hotel.hotel[0].hotelBasicInfo
return (
<Grid item xs={12} sm={6} md={4} key={hotelNo}>
<Card style={{ position: 'relative' }}>
<Label color={this.props.theme.palette.secondary.main}>{hotelMinCharge}円〜</Label>
<CardMedia
component="img"
image={hotelImageUrl}
alt={hotelName}
height="250"
style={{ objectFit: 'cover' }}
/>
<CardContent>
<Typography gutterBottom variant="h6" component="h2">
{hotelName}
</Typography>
</CardActions>
</Card>
</Grid>
)
})}
</Fragment>
)
}
}
export default withTheme()(HotelList)
上記コード一部割愛しています。
./actions/types.js
export const GET_HOTELS = 'GET_HOTELS'
export const FETCH_HOTEL = 'FETCH_HOTEL'
./actions/index.js
ホテル一覧を取得するためのgetHotels()
と
ホテル詳細を取得するためのfetchHotel()
を書いてます。
(ホテル詳細はいらなかったかもと思っています。)
import { GET_HOTELS, FETCH_HOTEL } from './types'
import { RakutenTravelClient } from '../libs/RakutenTravelApi'
const applicationId = process.env.RAKUTEN_API_KEY
export const getHotels = ({ checkInDate, checkOutDate, latitude, longitude }) => async dispatch => {
const params = {
applicationId: applicationId,
checkinDate: checkInDate,
checkoutDate: checkOutDate,
latitude: latitude,
longitude: longitude,
datumType: 1
}
const response = await RakutenTravelClient.get(`/20170426`, { params })
dispatch({ type: GET_HOTELS, payload: response.data.hotels })
}
export const fetchHotel = ({ id, checkInDate, checkOutDate }) => async dispatch => {
const params = {
applicationId: applicationId,
checkinDate: checkInDate,
checkoutDate: checkOutDate,
hotelNo: id
}
const response = await RakutenTravelClient.get(`/20170426`, { params })
dispatch({ type: FETCH_HOTEL, payload: response.data.hotels[0] })
}
./reducers/index.js
小さなアプリなので必要ないですが、参考書通りにcombineReducers
を使っています。
import { combineReducers } from 'redux'
import hotelReducer from './hotelReducer'
import hotelsReducer from './hotelsReducer'
export default combineReducers({
hotels: hotelsReducer,
hotel: hotelReducer
})
./reducers/hotelsReducer.js
取得したデータをreturnさせてます
import { GET_HOTELS } from '../actions/types'
export default (state = {}, action) => {
switch (action.type) {
case GET_HOTELS:
return action.payload
default:
return state
}
}
./reducers/hotelReducer.js
ホテル詳細の方のデータをreturnさせてます
import { FETCH_HOTEL } from '../actions/types'
export default (state = [], action) => {
switch (action.type) {
case FETCH_HOTEL:
return action.payload
default:
return state
}
}
ホテル詳細
componentDidMount()
内でactionで指定したfetchHotel()
を実行しています。URLのパラメータになっているチェックイン日とチェックアウト日を取得するのにquery-string
を使っています。ホテルIDと宿泊期間でホテル詳細を取得する仕様です。
また、ホテル詳細と合わせてホテルの宿泊プランもデータで持って来られるので、宿泊プランデータを<HotelPlanList />
コンポーネントに渡しています。
import React from 'react'
import queryString from 'query-string'
import { connect } from 'react-redux'
import { fetchHotel } from './actions'
import HotelPlanList from './components/HotelPlanList'
import styled from 'styled-components'
import Grid from '@material-ui/core/Grid'
import Typography from '@material-ui/core/Typography'
import Chip from '@material-ui/core/Chip'
import Button from '@material-ui/core/Button'
import List from '@material-ui/core/List'
import ListItem from '@material-ui/core/ListItem'
import ListItemText from '@material-ui/core/ListItemText'
import Divider from '@material-ui/core/Divider'
import Avatar from '@material-ui/core/Avatar'
import DirectionsCar from '@material-ui/icons/DirectionsCar'
import RateReview from '@material-ui/icons/RateReview'
class HotelDetail extends React.Component {
constructor(props) {
super(props)
this.state = {
checkInDate: '',
checkOutDate: '',
}
}
componentDidMount() {
const id = this.props.match.params.id
const locationValues = queryString.parse(this.props.location.search)
const { checkInDate, checkOutDate } = locationValues
this.setState({ checkInDate, checkOutDate })
// actionsで指定した楽天トラベルAPIのホテル詳細取得
this.props.fetchHotel({ id, checkInDate, checkOutDate })
}
render() {
if (!this.props.hotel.hotel) {
return <div>Loading...</div>
}
// this.props.hotelの`hotelBasicInfo`と`roomInfo`を変数に入れる
let hotelBasicInfo = []
let hotelPlans = []
this.props.hotel.hotel.forEach(data => {
if( data.hotelBasicInfo ) {
hotelBasicInfo = data.hotelBasicInfo
}
if ( data.roomInfo ) {
hotelPlans.push(data.roomInfo)
}
})
const {
hotelName, hotelMinCharge, reviewAverage, hotelImageUrl, hotelSpecial, access, userReview, hotelInformationUrl
} = hotelBasicInfo
return (
<Wrapper>
<Typography component="h2" variant="h3">
{hotelName}
</Typography>
<div>
<Chip color="secondary" label={`${reviewAverage}点`} />
<Chip color="primary" label={`${hotelMinCharge}円〜`} />
</div>
<Grid container spacing={24}>
<Grid item xs={12} sm={6}>
<ImageWrap>
<img src={hotelImageUrl} />
</ImageWrap>
<Button href={hotelInformationUrl} target="_blank" variant="contained" color="primary" size="large">詳細を見る</Button>
</Grid>
<Grid item xs={12} sm={6}>
<List>
<ListItem>
<ListItemText primary={hotelSpecial} />
</ListItem>
</List>
</Grid>
</Grid>
<section>
<Typography component="h3" variant="h6">
このホテルの宿泊プラン
</Typography>
<Typography component="p">
【対象期間】{this.state.checkInDate} ~ {this.state.checkOutDate}
</Typography>
<HotelPlanList hotelPlans={hotelPlans} />
</section>
</Wrapper>
)
}
}
const mapStateToProps = state => {
return { hotel: state.hotel }
}
export default connect(mapStateToProps, { fetchHotel })(HotelDetail)
一部コード省略してます
ホテルプラン一覧
渡ってきたホテルプラン一覧を表示するためのコンポーネントです。
import React from 'react'
import styled from 'styled-components'
import Grid from '@material-ui/core/Grid'
import Card from '@material-ui/core/Card'
import CardContent from '@material-ui/core/CardContent'
import Button from '@material-ui/core/Button'
import Typography from '@material-ui/core/Typography'
const HotelPlanList = ({ hotelPlans }) => {
return (
<Grid
container
direction="row"
justify="space-between"
alignItems="center"
spacing={16}
>
{hotelPlans.map((hotelPlan, index) => {
const { planName, roomName, reserveUrl } = hotelPlan[0].roomBasicInfo
const { total } = hotelPlan[1].dailyCharge
return (
<Grid item xs={12} sm={6} key={index}>
<Card>
<CardContent>
<Typography gutterBottom variant="subtitle1" component="h4">
{planName}
</Typography>
<Typography gutterBottom variant="body2" component="p">
{roomName}
</Typography>
<Grid
container
direction="row"
justify="space-between"
alignItems="flex-end"
>
<Label>{total}円</Label>
<Button href={reserveUrl} target="_blank" variant="contained" color="primary">予約ページへ</Button>
</Grid>
</CardContent>
</Card>
</Grid>
)
})}
</Grid>
)
}
export default HotelPlanList
const Label = styled.span`
display: block;
padding: 5px 10px;
font-size: 100%;
font-weight: 700;
line-height: 1;
text-align: center;
color: #fff;
background-color: #ff9800;
`
【作ってみて】
このサイトが形になったのはUdemyのお陰と言っても過言ではないと思ってます。
このアプリを作る前にはこれで勉強していました。
- Udemy
- ES6
- フロントエンドエンジニアのためのReact・Reduxアプリケーション
- Modern React with Redux
- React公式サイトチュートリアル
- React現場の教科書 参考書読破
- Redux公式サイトチュートリアル(Todoリスト)
- commonのソース
- 自作
- Todoリスト
- axiosを使った簡単な本の検索アプリ
本当だったら取得中とデータ取得成功、失敗でactionを分けるんだったなぁと思うのですが、それを思ったのは作り終えた後だったので、それはまた次の自作アプリで実装していきます。
【反省】
小さいアプリすぎて、Reduxの恩恵をカケラも受けませんでした。。次はTwitterAPIを作ってTwitterクライアントを作成中なので、こちらでもっと拡張した勉強をしようと思います。
【終わりに】
やろうと思えばこのアプリをもっと見やすいコードになっただろうし、改善の余地が十分ありすぎる作品ですが、今回はこの辺りで終了しようと思います。
次回に期待!!!!