Reduxで非同期処理を始める
Redux-thunkかRedux-sagaを使うのが一般的だと思いまが、ここではRedux-sagaについて触れます。
たぶんRedux-sagaの内容に関する記事等はあると思うので機能やシステムについてはあまり触れません。
基本的にコードメインで書いてます。
まずReduxとRedux-saga、あとredux-devtools-extensionを入れておきます。
$ cd your_project_client
$ yarn add redux react-redux @types/react-redux redux-devtools-extension redux-saga
そして、src/
配下のindex.tsx
にReduxの設定を行います。
import React from 'react'
import ReactDOM from 'react-dom'
import App from './components/App'
import createSagaMiddleware from 'redux-saga'
import { createStore, compose, applyMiddleware } from 'redux'
import { devToolsEnhancer } from 'redux-devtools-extension'
import { Provider } from 'react-redux'
import RootReducer from './reducers'
import RootSaga from './sagas'
const sagaMiddleware = createSagaMiddleware()
const store = createStore(
RootReducer,
compose(applyMiddleware(sagaMiddleware), devToolsEnhancer({})),
)
sagaMiddleware.run(rootSaga)
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
RootReducerについて
src/index.tsx
でimportしているRootReducerは./reducers/index.ts
に定義します。
exampleReducerの中身はこのあと作っていきます。
*CombinedReducers型のimport元の@modelsはwebpack.config.jsでsrc/models
のエイリアスに設定しています。
import { combineReducers } from 'redux'
import { CombinedReducers } from '@models'
// CombinedReducers型の中身の例
// interface CombinedReducers {
// example: { data: string[] }
// }
import exampleReducer from './examples'
// exampleReducer.dataは文字列の配列
export const RootReducer: CombinedReducers = combineReducers({
example: exampleReducer,
})
RootSagaについて
src/index.tsx
でimportしているRootSagaは、./sagas/index.ts
に定義します。
exampleWatcherの中身はこのあと作っていきます。
import { all, fork } from 'redux-saga/effects'
import { exampleWatcher } from './examples'
export default function* RootSaga() {
yield all([fork(exampleWatcher)])
}
Reduxのシステムを作る
Redux自体については前提知識として、以下の記事など学習に良さそうでした。
https://qiita.com/mpyw/items/a816c6380219b1d5a3bf
以下3点に修正を加えます。
-
actions/example.ts
(上に書いたsrc/index.tsx
と同じディレクトリ内に作る。reducers
やsagas
共同じディレクトリ) reducers/example.ts
-
components/App.tsx
(上に書いたsrc/index.tsx
でimportしているやつ)
import Models from '@models'
interface Request {
userId: string
}
interface Response {
data: string[]
}
// 別ファイルに分けても良いかも
export namespace Types {
export const STARTED_GET_DATA = 'STARTED_GET_DATA'
export const SUCCEEDED_GET_DATA = 'SUCCEEDED_GET_DATA'
export const FAILED_GET_DATA = 'FAILED_GET_DATA'
}
export const getData = {
// 取得開始を宣言するアクション
started: (request: Request) => {
return {
type: Types.STARTED_GET_DATA as typeof Types.STARTED_GET_DATA,
payload: request, // sagaに渡した時に、payload.userIdで取得する
}
},
// 取得完了成功を宣言するアクション
succeeded: (response: Response) => ({
type: Types.SUCCEEDED_GET_DATA as typeof Types.SUCCEEDED_GET_DATA,
payload: response,
}),
// 取得完了失敗を宣言するアクション
failed: (response: Response) => ({
type: Types.FAILED_GET_DATA as typeof Types.FAILED_GET_DATA,
payload: response,
}),
}
export type DataAction =
| ReturnType<typeof getData.started>
| ReturnType<typeof getData.succeeded>
| ReturnType<typeof getData.failed>
import {
ExampleAction,
Types,
} from '@actions/example'
export interface ExampleState {
data: string[]
startedGetData: boolean
getDataStatus: {
succeeded: boolean
failed: boolean
}
}
export const initialState: ExampleState = {
data: [],
startedGetData: false,
getDataStatus: {
succeeded: false,
failed: false,
},
}
const exampleReducer = (
state: ExampleState = initialState,
action: ExampleAction,
): ExampleState => {
switch(action.type) {
case Types.STARTED_GET_DATA:
return {
...state,
startedGetData: true,
}
case Types.SUCCEEDED_GET_DATA:
return {
...state,
data: action.payload.data, // APIの返却方法によって形が変わる
startedGetData: false,
getDataStatus: {
succeeded: true,
failed: false
},
}
case Types.FAILED_GET_DATA:
return {
...state,
startedGetData: false,
getDataStatus: {
succeeded: false,
failed: true,
},
}
default:
console.warn(`There is something wrong with the action passed: ${JSON.stringify(action, null, 2)}`)
return state
}
}
export default exampleReducer
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { CombinedReducers } from '@models' // reducers/index.tsでimportしたのと同じもの
import { getData } from '@actions/example'
const App: React.FC = () => {
const dispatch = useDispatch()
const data: Example[] = useSelector((state: CombinedReducers) => {
return state.example.data
})
React.useState(() => {
// APIを呼んでdataにデータを注入する。
dispatch(getData.started)
}, [])
return (
<>
<h1>Datas</h1>
{
datas.map((item: string, index: number) => (
<div key={index}>
<p>{item}</p>
</div>
))
}
</>
)
}
export default App
ここまででApp.tsxを表示した際にdispatch(getData.started)
が実行され、
アクションクリエイター(actions/example
のgetData.started
)を経て、
exampleReducer
のType. STARTED_GET_DATA
からステイトが返却されます。
ですがこのままではAPIへのリクエストを出していないので、reducers/example.ts
のinitialState.startedGetData
がtrue
に変化するだけとなってしまいます。
(ReduxDevtoolsのchrome拡張機能が入っているのであればそこでも確認できます)
ですので、ここからAPIリクエストを出す部分のコードを書いていきます。
ここでRedux-sagaが登場します。
Redux-sagaで非同期処理を行う
非同期処理を行うために、App.tsx
内に処理を書けばいいじゃん?と考える人もいるかもしれませんが、それはあまり推奨されません。
コンポーネントの中に書くと記述が煩雑になりますし、もし他の場所で同様のAPIを呼ぶ場合などがあるとコードの可用性が損なわれてしまいます。
ですので、コンポーネント外で非同期の処理を行うわけですが、ここでRedux-sagaとRedux-thunkが登場します。
基本的にどちらを使うかは好みによりますが、コードの可読性や可用性を考慮するとRedux-sagaの方が個人的にはオススメです(Redux-sagaは従来のReduxアーキテクチャとは疎結合して動いているので、Redux-thunkよりかはスッキリした書き方ができます)。
ただ、正直なところRedux-sagaはRedux-thunkより学習コストが高いです。しっかりしている分仕方がないと思いますが、軽く手っ取り早く初心者が非同期でReduxを使いたいような場合にはもしかするとRedux-thunkのほうが向いているかもしれないです。
sagas
に下記のファイルを追加する。
import { call, put, takeLatest } from 'redux-saga/effects'
import { getData, Types } from '@actions/example'
import { getDataApiFunction } from '@apis/example'
// ジェネレータ関数でyieldごとに非同期で処理を実行していく
function* getDataSaga(action: ReturnType<typeof getData.started>) {
console.log(`action: ${JSON.stringify(action, null, 2)}`)
// action: {
// "type": "STARTED_GET_DATA",
// "payload": { "userId": "XXXXX" }
// }
try {
// userIdをクエリとするために引数にpayload.userIdを含めてgetDataApiFunction()を実行する
const result = yield call(getDataApiFunction(), action.payload.userId)
console.log(`result: ${JSON.stringify(result, null, 2)}`)
// result: ["hoge", "foo", "bar", "baz"]
// 取得完了成功宣言
yield put(getFiles.succeeded(result))
} catch(err) {
console.error('Failed to get data')
// 取得完了失敗宣言
yield put(getFiles.failed({ data: [] }))
}
}
// これをexportしてsagas/index.ts内のRootSaga内でforkしてあげます。
// そうすると、Redux-sagaはtakeLatestの第一引数のアクションが作成された際にgetDataSagaを実行するようになります。
export function* exampleWatcher() {
yield takeLatest(Action.STARTED_GET_DATA, getDataSaga)
}
そして、最後にAPIリクエストを出している部分をapis
に追加します。
ここに記述するgetDataApiFunction
が先ほどgetDataSaga
内で最初にyieldで非同期実行する内容です。
import axios from 'axios'
interface ApiConfig {
baseURL: string
timeout: number
}
const API_CONFIG: ApiConfig = {
baseURL: 'API URI to get data',
timeout: 7000
}
export const getDataApiFunction = async (userId: string) => {
const instance = axios.create(API_CONFIG)
try {
// ここでデータ取得
const response = await instance.get(`${API_CONFIG.baseURL}?userId=${userId}`)
if (response.status !== 200) {
throw new Error(`Failed: status code is ${response.status}`)
}
console.log(`response: ${JSON.stringify(response, null, 2)}`)
const data: string[] = response.data
return data
} catch(err) {
throw new Error(err)
}
}
上記のgetDataApiFunction
の実行が滞りなく行われると、
sagas/example.ts
のgetDataSaga
の処理は
yield put(getFiles.succeeded(result))
まで進み、
reducers/example.ts
のSUCCEEDED_GET_DATA
の、文字列配列dataを含めたステイトを返却します。
すると、Api.tsx
のmapしている部分までデータが渡り、ブラウザにデータが表示されるようになります。
以上でRedux-sagaを使った非同期処理を一通り実装できます。
一応主観ですが、わかりやすく書くためにinterfaceをファイルごとに同じものを定義しましたが、本来はどこかにまとめてそこを見にいくようにした方が良いです。
最終的なsrc
ディレクトリ内の構成
.
├── actions
│ └── example.ts
├── components
│ └── App.tsx
├── index.tsx
├── models
│ └── index.ts
├── public
│ ├── index.css
│ └── index.html
├── reducers
│ ├── example.ts
│ └── index.ts
├── sagas
│ ├── example.ts
│ └── index.ts
└── apis
└── example.ts