前回の記事では同期的なTODOアプリを例としてReact + Reduxの基本的な使い方を学びなおしました。
今回は非同期処理の扱い方を勉強してサンプルのアプリケーションを作ったので、要点などをメモしておきます。
Reduxでの非同期処理について
実際のアプリケーションではAPI通信など非同期処理が必ずと言っていいほど入ってくると思います。
たとえば fetch()
を使って何かデータを取ってくる Action creator を定義したいとします。
const fetchSomeThing = (url) => { return fetch(url) .then(res => res.json()) .then(json => { return { type: 'FETCH_DATA', payload: { response: json } } }); }; fetchSomeThing('APIのURL').then(response => store.dispatch(response));
上記のようにすればfetchした結果で得たActionをdispatchすることは可能ですが、正直使いにくいです。
できることなら、同期的な処理と同じような感じで、store.dispatch(fetchSomeThing('APIのURL'))
とできると嬉しいですね。
さらにAPIコールする場合、ローディング中やローディング完了後、さらにエラーが発生した場合などの考慮をしなくてはなりません。
つまり下記の Action と Action creatror が必要になります。
- リクエスト開始を知らせるAction
isFetchig: true
などにしてローディング中の対応をします
例)
const requestData = url => ({ type: 'REQUEST_DATA', payload: { url } });
- リクエスト成功を知らせるAction
isFetching: false
のようにリセットして、取得したデータを任意のpropertyに追加します
例)
const receiveData = response=> ({ type: 'RECEIVE_DATA', payload: { response } });
- リクエスト失敗を知らせるAction
isFetching: false; isError: true
などのようにしてエラーの対応をします
例)
const failReceiveData= error => ({ type: 'FAIL_RECEIVE_DATA', error: true, payload: error });
これを先ほどの、fetchSomeThing
に当て込んでみます。
const fetchSomeThing = url => { return requestData(url); // 詰みました orz fetch(url).then(res =>... }
このように上手く処理できません。
同期的なAction creatorを非同期処理の中でうまく使えるようにするには、Reduxのミドルウェアである Redux-thunk を使うのが一般的です。
Redux-thunkとは
Redux-thunkを使用すると、Action creator はオブジェクトの代わりに 関数を返すことができるようになります。
関数を返す Action creator は Thunk となります。
Thunkが返す関数は、Redux-thunkのミドルウェアによって実行されます。
この関数は、Reducerのように純粋な関数でなくても良く、つまりAPI呼び出しなどの非同期を伴ったりしても問題ありません。
またこの関数は dispatch
を第1引数に持たせることができるため、関数内で Action を Dispatch することができます。
(第2引数には getState
を持たせることができます。これにより、関数内で Store の state を見ることができます)
この特性を生かすと先ほどの fetchSomeThing
は下記のように記述することができます。
const fetchSomeThing = (url) => { return (dispatch) => { // リクエスト開始のActionをdispatch dispatch(requestData(url)); return fetch(url) .then(res => { if (!res.ok) { return Promise.resolve(new Error(res.statusText)); } return res.json(); }) .then(json => { // レスポンスの受け取り(リクエスト成功)のActionをdispatch dispatch(receiveData(json)) }) .catch(error => { // リクエスト失敗のActionをdispatch dispatch(failReceiveData(error)); }); } };
このように非同期の中でも、Actionをdispatchすることでstoreのstateを更新するのは変わりません。
ちなみに上記はPromiseを返していますので、呼び出し側では then()
などで処理を直列で実行できます。
store.dispatch(fetchSomeThing('APIのURL')) .then(() => /* something do... */);
Redux-thunkの取り込み方
Redux-thunkはミドルウェアなので、createStore()
実行時に、applyMiddleware
というエンハンサー(強化プログラム)を使って取り込みます。
import { createStore, applyMiddleware } from 'redux' import thunk from 'redux-thunk' import rootReducer from './reducers' const middlewares = [thunk]; // 他にミドルウェアを追加する場合はここに入れる const store = createStore( rootReducer, applyMiddleware(...middlewares) );
以上が基本的なRedux-thunkを使った非同期処理のやり方です。
サンプルアプリケーション
React + Redux + Redux-thunk を使って、公式チュートリアルを真似た簡単なサンプルを作りました。
仕様
- ページロードしたら、タグを取得するAPI(ただ
['react', 'vue', 'angular']
を返すだけ)をコールしてボタンを表示 - ボタンをクリックすると、Qiitaのそのタグ情報を取得するAPIをコールして、タグ情報を表示
- 一度取得したタグ情報は保存しておき、「再読み込み」をクリックしない限り、再度APIコールしない
stateの形
やはりstateの形からデザインするのがよさそうです。
{ // タグ取得に関するstate tags: { tagAll: ['react', 'vue', 'angular'], isFetching: false, isError: { status: false, error: null }, selectedTag: 'react', }, // タグの詳細情報に関するstate tagDatas: { react: { isFetching: false, isError: { status: false, error: null }, shouldUpdate: false, lastUpdated: 137983721 responseData: { "followers_count": 298, "icon_url": "https://s3-ap-northeast-1.amazonaws.com/qiita-tag-image/e6867d326364bb2498f72f152c92408bf457de8c/medium.jpg?1426679594", "id": "react.js", "items_count": 262 } } } } /** * 初期stateはこんな感じ */ { tags: { tagAll: [], isFetching: false, isError: { status: false, error: null }, selectedTag: '' }, tagDatas: {} }
Action type
次にAction Typeを考えます。
REQUEST_TAGS
- タグ一覧をリクエスト
RECEIVE_TAGS
- タグ一覧を受け取り
FAIL_REQUEST_TAGS
- タグ一覧のリクエスト失敗
SELECT_TAG
- タグを選択
REQUEST_TAG_DATA
- タグの詳細情報をリクエスト
RECEIVE_TAG_DATA
- タグの詳細情報を受け取り
REFRESH_TAG_DATA
- タグの情報を再読み込み
FAIL_REQUEST_TAG_DATA
- タグの詳細情報のリクエスト失敗
Action creator
/** * Action Creator */ export const requestTags = () => ({ type: REQUEST_TAGS }); export const receiveTags = (json) => ({ type: RECEIVE_TAGS, payload: { response: json } }); export const selectTag = (tag) => ({ type: SELECT_TAG, payload: { tag } }); export const requestTagData = (tag) => ({ type: REQUEST_TAG_DATA, payload: { tag } }); export const receiveTagData = (tag, json) => ({ type: RECEIVE_TAG_DATA, payload: { tag, response: json } }); export const refreshTagData = (tag) => ({ type: REFRESH_TAG_DATA, payload: { tag } }); export const failRequestTags = (error) => ({ type: FAIL_REQUEST_TAGS, error: true, payload: { error } }); export const failRequestTagData = (tag, error) => ({ type: FAIL_REQUEST_TAG_DATA, error: true, payload: { tag, error } }); /** * Thunk * タグ一覧を取得する */ export const fetchTags = () => { return (dispatch) => { dispatch(requestTags()); return fetch(API_GENRE) .then(res => { if (!res.ok) { return Promise.resolve(new Error(res.statusText)); } return res.json(); }) .then(json => dispatch(receiveTags(json))) .catch(error => dispatch(failRequestTags(error))); } }; /** * Thunk * タグの詳細情報を取得する */ const fetchTagData = (tag) => { return (dispatch) => { dispatch(requestTagData(tag)); return fetch(API_QIITA_TAGS + tag) .then(res => { if (!res.ok) { return Promise.resolve(new Error(res.statusText)); } return res.json(); }) .then(json => dispatch(receiveTagData(tag, json))) .catch(error => dispatch(failRequestTagData(tag, error))); } } /** * タグの詳細情報を取得するか判定 * (すでに対象のタグの詳細情報を取得済みであればfalseを返す) */ const shouldFetchTagData = (tag, state) => { if (state.tagDatas[tag] === undefined || state.tagDatas[tag].shouldUpdate) { return true; } if (state.tagDatas[tag].isFetching) { return false; } return false; }; /** * Thunk * タグの詳細情報を必要であれば取得する */ export const fetchTagDataIfNeeded = (tag) => { return (dispatch, getState) => { if (shouldFetchTagData(tag, getState())) { return dispatch(fetchTagData(tag)); } } };
ここでのポイントになるのは、fetchTagDataIfNeeded
でしょうか。
このアプリケーションでは、一度タグ詳細情報を取得している場合、再読み込みボタンを押さない限り再度APIコールを行いません。
そのロジックを呼び出し側で行うと、view側で条件分岐などをして fetchTagData
を実行する処理を書かなくてはなりません。
しかしそのロジックをAction Creatorに持たせれば、view側では気にせずにそのAction Creatorをdispatchするだけというシンプルな作りになります。
またThunkからThunkをdispatchできるのもThunkの使い勝手が良いところです。
Reducer
▼ /src/reducers/index.js
import { combineReducers } from 'redux'; import tags from './tags'; import tagDatas from './tagDatas'; const rootReducer = combineReducers({ tags, tagDatas }); export default rootReducer;
▼ /src/reducers/tags.js
import * as Actions from '../actions'; function selectedTag(state = '', action) { switch (action.type) { case Actions.SELECT_TAG: return action.payload.tag; default: return state; } } function tagsIsFetching(state = false, action) { switch (action.type) { case Actions.REQUEST_TAGS: return true; default: return false; } } function tagsIsError(state = { status: false, error: null }, action) { switch (action.type) { case Actions.FAIL_REQUEST_TAGS: return { ...state, status: true, error: action.payload.error }; default: return { ...state, status: false, error: null }; } } /** * タグ一覧 のstateを管理するReducer */ export default function tags(state = { tagAll: [], isFetching: false, isError: { status: false, error: null }, selectedTag: '' }, action) { switch (action.type) { case Actions.REQUEST_TAGS: return { ...state, isFetching: tagsIsFetching(state.isFetching, action) }; case Actions.RECEIVE_TAGS: return { ...state, tagAll: action.payload.response, isFetching: tagsIsFetching(state.isFetching, action) }; case Actions.SELECT_TAG: return { ...state, selectedTag: selectedTag(state.selectedTag, action) }; case Actions.FAIL_REQUEST_TAGS: return { ...state, isError: tagsIsError(state.isError, action), isFetching: tagsIsFetching(state.isFetching, action) }; default: return state; } }
▼ /src/reducers/tagDatas.js
import * as Actions from '../actions'; function tagData(state = { isFetching: false, isError: { status: false, error: null }, shouldUpdate: false, responseData: {} }, action) { switch (action.type) { case Actions.REQUEST_TAG_DATA: return { ...state, isFetching: true, shouldUpdate: false }; case Actions.RECEIVE_TAG_DATA: return { ...state, isFetching: false, isError: { status: false, error: null }, lastUpdate: Date.now(), responseData: action.payload.response }; case Actions.REFRESH_TAG_DATA: return { ...state, shouldUpdate: true }; case Actions.FAIL_REQUEST_TAG_DATA: return { ...state, isFetching: false, isError: { status: true, error: action.payload.error } }; default: return state; } } /** * タグの詳細情報を管理するReducer */ export default function tagDatas(state = {}, action) { switch (action.type) { case Actions.REQUEST_TAG_DATA: case Actions.RECEIVE_TAG_DATA: case Actions.REFRESH_TAG_DATA: case Actions.FAIL_REQUEST_TAG_DATA: return { ...state, [action.payload.tag]: tagData(state[action.payload.tag], action) }; default: return state; } }
少々複雑なstateになるため、Reducer を適当な大きさに分けています。
ここでのポイントは、tag.js
と tagData.js
内で Reducer合成 を行っている点です。
stateがネストしている場合、適度にReducer合成を行うことで、Reducerごとに責任を分けることができて管理しやすくなる利点があります。
特定のStateの状態がおかしいとき、そのReducerに注目すればトラブルシューティングがしやすいです。
Store
▼/src/store/configureStore.js
import { createStore, applyMiddleware } from 'redux'; import rootReducer from '../reducers'; import thunk from 'redux-thunk'; import { createLogger } from 'redux-logger'; const logger = createLogger(); const middlewares = [thunk, logger]; export default function configureStore(pleloadState) { return createStore( rootReducer, pleloadState, applyMiddleware(...middlewares) ); };
redux-thunk
の他に、デバッグ用のログをとる redux-logger
を使用します。
ここで要注意なのが、ミドルウェアを記述する順番です。
const middlewares = [thunk, logger];
ではなく、const middlewares = [logger, thunk];
と、redux-logger
を先に入れてしまうと、console上で undefined
のActionが実行される事象が発生する可能性があります。
※Redux-thunk の リポジトリに issue として挙がっていました。
github.com
Storeを作ったらUIは別としてデータロジックのテストができます。
import configureStore from './store/configureStore'; import * as Actions from './actions'; const store = configureStore(); store.dispatch(Actions.fetchTags()) .then(() => { store.dispatch(Actions.selectTag('vue')); store.dispatch(Actions.fetchTagDataIfNeeded('vue')); });
開発者ツールのコンソール上で意図した実行結果になっているか確認します。
問題なさそうです!
あとはコンポーネントを作り、Reduxとつなげる処理をします。
コンポーネント設計
コンポーネントは下記のように分けることにしました。
赤がコンテナコンポーネント(Storeと繋ぐコンポーネント)、青がプレゼンテーショナルコンポーネントです。
適当に作ったので見にくいですが、階層的には下記のような感じです。
- App
コンポーネント作成
ここからあとはひたすらにコンポーネントを作っていきますが、エントリーポイントとなるjsファイルの内容は下記になります。
▼ /src/index.js
import React from 'react'; import { render } from 'react-dom'; import configureStore from './store/configureStore'; import { Provider } from 'react-redux'; import App from './components/App'; const store = configureStore(); render( <Provider store={store}> <App /> </Provider>, document.getElementById('root') );
プレゼンテーショナルコンポーネント
propsから受けとる値を元に、見た目を作るコンポーネントです。
▼ /src/components/Btn.jsx
import React from 'react'; import styled from 'styled-components'; const Btn = ({ tag, children, onClick }) => { const handleClick = () => onClick(tag); return ( <Wrapper> <ItemWrapper onClick={handleClick}>{children}</ItemWrapper> </Wrapper> ); }; const Wrapper = styled.li` // styleのため省略 `; const ItemWrapper = styled.button` // styleのため省略 `; export default Btn;
▼ /src/components/BtnGroup.jsx
import React, { Component } from 'react'; import styled from 'styled-components'; import Btn from './Btn'; import { fetchTags, selectTag, fetchTagDataIfNeeded, } from '../actions'; class BtnGroup extends Component { constructor(props) { super(props); this.handleClick = this.handleClick.bind(this); } componentDidMount() { const { dispatch } = this.props; dispatch(fetchTags()); } handleClick(tag) { const { dispatch } = this.props; dispatch(selectTag(tag)); dispatch(fetchTagDataIfNeeded(tag)); } render() { const { tagAll, isFetching, isError } = this.props; return ( <Wrapper> {isFetching && <Message>Now Loading...</Message> } {isError.status && <Message>{isError.error.message}</Message> } {tagAll.length > 0 && <React.Fragment> <ListHead>タグ:</ListHead> <ListWrapper> {tagAll.map(tag => <Btn key={tag} tag={tag} onClick={this.handleClick}>{tag}</Btn>)} </ListWrapper> </React.Fragment> } </Wrapper> ) } } const Wrapper = styled.div` // styleのため省略 `; const Message = styled.p` // styleのため省略 `; const ListWrapper = styled.ul` // styleのため省略 `; const ListHead = styled.h2` // styleのため省略 ` export default BtnGroup;
▼ /src/components/Head.jsx
import React from 'react'; import styled from 'styled-components'; const Head = ({ tag }) => ( <Wrapper> Qiitaの記事タグ「{tag}」の詳細情報 </Wrapper> ); const Wrapper = styled.h1` // styleのため省略 `; export default Head;
▼ /src/components/LastUpdate.jsx
import React from 'react'; import styled from 'styled-components'; const LastUpdate = ({ lastUpdate }) => { return ( <Wrapper> 最終更新日時: {new Date(lastUpdate).toLocaleTimeString()} </Wrapper> ); }; const Wrapper = styled.span` // styleのため省略 `; export default LastUpdate;
▼ /src/components/RefreshLink.jsx
import React from 'react'; import styled from 'styled-components'; const RefreshLink = ({ onClick }) => ( <Wrapper href="#" onClick={onClick}>再読み込み</Wrapper> ); const Wrapper = styled.a` // styleのため省略 `; export default RefreshLink;
▼ /src/components/Details.jsx
import React from 'react'; import styled from 'styled-components'; const Details = ({ followers_count, icon_url, items_count }) => ( <Wrapper> <ItemWrapper>フォロワー数: {followers_count}</ItemWrapper> <ItemWrapper>アイコン画像: <Img src={icon_url} /></ItemWrapper> <ItemWrapper>記事数: {items_count}</ItemWrapper> </Wrapper> ); const Wrapper = styled.ul` // styleのため省略 `; const ItemWrapper = styled.li` // styleのため省略 `; const Img = styled.img` // styleのため省略 `; export default Details;
▼ /src/components/TagDetail.jsx
import React from 'react'; import styled from 'styled-components'; import Head from './Head'; import LastUpdate from './LastUpdate'; import RefreshLink from './RefreshLink'; import Details from './Details'; import { refreshTagData, fetchTagDataIfNeeded } from '../actions'; const TagDetails = ({ selectedTag, tagDatas, dispatch }) => { const selectedTagData = tagDatas[selectedTag]; if (!selectedTagData) { return null; } const { isError, isFetching, lastUpdate, responseData } = selectedTagData; const onRefresh = (e) => { e.preventDefault(); dispatch(refreshTagData(selectedTag)); dispatch(fetchTagDataIfNeeded(selectedTag)); }; const hasResponseData = Object.keys(responseData).length > 0; return ( <Wrapper> <Head tag={selectedTag} /> {isFetching && !hasResponseData && <Message>Now loading...</Message> } {isError.status && <Message error>{isError.error.message}</Message> } <div style={{ opacity: isFetching ? 0.5 : 1 }}> {lastUpdate && <LastUpdate lastUpdate={lastUpdate} /> } <RefreshLink onClick={onRefresh} /> {hasResponseData && <Details {...responseData} /> } </div> </Wrapper> ); }; const Wrapper = styled.div``; const Message = styled.p` // styleのため省略 `; export default TagDetails;
▼ /src/components/App.jsx
import React, { Component } from 'react'; import BtnGroup from '../containers/BtnGroup'; import TagDetail from '../containers/TagDetail'; class App extends Component { render() { return ( <div> <BtnGroup /> <TagDetail /> </div> ); } }; export default App;
コンテナコンポーネント
コンテナコンポーネントは Store とつながることで state を参照できます。
mapStateToProps
を使って、stateを元に子コンポーネントへpropsで必要な情報を渡します。
基本的にはコンテナコンポーネントには jsx(見た目)を書くことはありません。
▼ /src/containers/BtnGroup.jsx
import { connect } from 'react-redux'; import BtnGroup from '../components/BtnGroup'; function mapStateToProps(state) { const { tagAll, isFetching, isError } = state.tags; return { tagAll, isFetching, isError }; } export default connect(mapStateToProps)(BtnGroup);
▼ /src/containers/TagDetail.jsx
import { connect } from 'react-redux'; import TagDetail from '../components/TagDetail'; function mapStateToProps(state) { const { tagDatas, tags } = state; return { selectedTag: tags.selectedTag, tagDatas } } export default connect(mapStateToProps)(TagDetail);
実装してみて気づいたのですが、mapDispatchToProps
でコールバック関数も props で渡すことが可能ですが、コールバック関数を追加したい場合など修正が発生したときに、呼び出し元と呼び出し先の両方でコードを修正しなくてはならず、ちょっと面倒に感じました。
ケースバイケースかと思いますが、dispatch
はそのままpropsから使って、呼び出し側でアクションを選択した方が良いかと感じました。
完成版
以上で完成になりますが、一部 styled-component
のスタイルなど省略しています。
完全版は下記リポジトリになります。
create-react-app2/study_thunk at master · kde-space/create-react-app2 · GitHub
まとめ
- Redux のミドルウェアである Redux-thunk を使うと非同期の Action も処理できる
- Redux-thunkを使うと、ただのオブジェクトではなく、
dispatch
とgetState
を引数に取る関数を返す Action Creator (Thunk)を作ることができる - 実装に進む前に State の形を考えた方がスムーズ
- APIなどの非同期通信の場合、3つの Action を用意する
- Reducer は 適度に分割して Reducer合成を行った方が見やすくなる
- Redux-logger は、
applyMiddleware()
の最後に渡した方が良い
まだ経験不足なので誤り、ご指摘あればぜひお願いします。