Edited at

reduxで非同期処理をするいくつかの方法(redux-thunk、redux-saga)


概要

reduxの非同期処理のやり方を調べたので、まとめるため以下のような非同期で動くカウンターを作ります。

reduxで非同期処理をどうするかというのは、結局なにに非同期処理を押し付けるかという問題になります。現在の主な押し付け先の選択肢は、以下の3種類のようです。


  • コンポーネント

  • Action (redux-thunk)

  • Saga (redux-saga)

それぞれのやり方でカウンター書いてみます。

今回は、以下のようなAPIを使ってカウンターを作ることにします。現在のカウンターの値を受け取って、一秒後に+1した値をresolveしています。カウンターのbuttonが押されると、何らかの方法で以下の非同期なAPIを呼んで、現在のstateから次のstateを計算します。また、今回の例ではこのAPIはつねにうまく動くとし、エラー処理は考えないことにします。


API.js

export const nextCountAPI = currentCount => {

return new Promise(resolve => {
setTimeout(() => {
resolve(currentCount + 1)
}, 1000)
})
}


コンポーネントに押し付ける

middlewareを何も使わず普通に書くと、非同期処理はコンポーネント中のイベントハンドラで行うようになると思います。


Promise

以下、コードです。


index.js

import React from 'react'

import ReactDOM from 'react-dom'
import { createStore } from 'redux'

import Counter from './Counter'

const initialState = {
count: 0,
isFetching: false,
}

const reducer = (state = initialState, action) => {
switch (action.type) {
case 'REQUEST_NEXT_COUNT':
return {
...state,
isFetching: true,
}
case 'RECEIVE_NEXT_COUNT':
return {
...state,
count: action.nextCount,
isFetching: false,
}
default:
return state
}
}

export const requestNextCount = () => ({
type: 'REQUEST_NEXT_COUNT',
})
export const receiveNextCount = nextCount => ({
type: 'RECEIVE_NEXT_COUNT',
nextCount,
})

const store = createStore(reducer)

const render = () => {
ReactDOM.render(
<Counter store={store} />,
document.getElementById('root')
)
}
render()
store.subscribe(render)



Counter.js

import React from 'react';

import { requestNextCount, receiveNextCount } from './index'
import { nextCountAPI } from './API'

const Counter = ({ store }) => {
const { count, isFetching } = store.getState()

const handleNext = () => {
store.dispatch(requestNextCount())
nextCountAPI(count).then(nextCount => {
store.dispatch(receiveNextCount(nextCount))
})
}

return (
<div>
<h1>{isFetching ? '...' : count}</h1>
<button onClick={handleNext}>next</button>
</div>
)
}

export default Counter


イベントハンドラ中で非同期APIを呼んでいます。この書き方のメリットは、とにかくシンプルでわかりやすいことです。何も難しいことをしておらず、誰が見ても何をしているのかわかります。デメリットとしては、複雑なアプリになってくるとイベントハンドラがごちゃごちゃしてきて見通しが悪くなることや、コードが冗長になりやすいことが挙げられるかと思います。


async / await

イベントハンドラを以下のようにasync/awaitを使って書き換えてあげると少しは見通しがよくなるかもしれません。


Counter.js

-  const handleNext = () => {

+ const handleNext = async () => {
store.dispatch(requestNextCount())
- nextCountAPI(count).then(nextCount => {
- store.dispatch(receiveNextCount(nextCount))
- })
+ const nextCount = await nextCountAPI(count)
+ store.dispatch(receiveNextCount(nextCount))
}


redux-thunk

次に、middlewareredux-thunkを使います。redux-thunkでは非同期処理をactionに押し付けます。押し付けられたactionは、通常のactionのようなプレーンなObjectではなく、関数になります。この関数中で、一つ前の例でイベントハンドラ中で行っていた処理をそのまま行うような感じです。

コードは以下です。


index.js

import React from 'react'

import ReactDOM from 'react-dom'
-import { createStore } from 'redux'
+import { createStore, applyMiddleware } from 'redux'
+import thunk from 'redux-thunk'

import Counter from './Counter'
+import { nextCountAPI } from './API'

const initialState = {
count: 0,
isFetching: false,
}

const reducer = (state = initialState, action) => {
switch (action.type) {
case 'REQUEST_NEXT_COUNT':
return {
...state,
isFetching: true,
}
case 'RECEIVE_NEXT_COUNT':
return {
...state,
count: action.nextCount,
isFetching: false,
}
default:
return state
}
}

export const requestNextCount = () => ({
type: 'REQUEST_NEXT_COUNT',
})
export const receiveNextCount = nextCount => ({
type: 'RECEIVE_NEXT_COUNT',
nextCount,
})
+export const nextCount = () => (dispatch, getState) => {
+ const { count } = getState()

+ dispatch(requestNextCount())
+ nextCountAPI(count).then(nextCount => {
+ dispatch(receiveNextCount(nextCount))
+ })
+}

-const store = createStore(reducer)
+const store = createStore(reducer, applyMiddleware(thunk))

const render = () => {
ReactDOM.render(
<Counter store={store} />,
document.getElementById('root')
)
}
render()
store.subscribe(render)



Counter.js

import React from 'react';

-import { requestNextCount, receiveNextCount } from './index'
+import { nextCount } from './index'
-import { nextCountAPI } from './API'

const Counter = ({ store }) => {
const { count, isFetching } = store.getState()

- const handleNext = () => {
- store.dispatch(requestNextCount())
- const nextCount = await nextCountAPI(count)
- store.dispatch(receiveNextCount(nextCount))
- }
+ const handleNext = () => {
+ store.dispatch(nextCount())
+ }

return (
<div>
<h1>{isFetching ? '...' : count}</h1>
<button onClick={handleNext}>next</button>
</div>
)
}

export default Counter


redux-thunkのメリットとしては、コンポーネントから見たときに処理が単純になることだと思います。Counter.jsのコードを見るとわかるように、コンポーネントは具体的な非同期処理の内容について一切気にせず、単に1つactiondispatchするだけになっていてわかりやすいです。一方、デメリットとしては、actionを関数にするというやり方自体がreduxのやり方から大きく外れてしまっていて気持ち悪いことや、テストがしづらいことがあると思います。


redux-saga

redux-sagaでは、非同期処理をSagaという独立したプロセスに押し付けます。redux-thunkと比べた場合のメリットとしては、非同期処理を行うactionもプレーンなObjectのまま保てることや、テストがしやすいことが挙げられます。また、複雑なアプリになるほどredux-sagaの力が発揮されるっぽいです。デメリットは、めんどくさいことだと思います。

まず、以下のようにSagaを定義するファイルを書きます。


sagas.js

import { call, put, takeEvery } from 'redux-saga/effects'

import { requestNextCount, receiveNextCount } from './index'
import { nextCountAPI } from './API'

function* nextCount(action) {
yield put(requestNextCount())
const nextCount = yield call(nextCountAPI, action.count)
yield put(receiveNextCount(nextCount))
}

export default function* rootSaga() {
yield takeEvery('NEXT_COUNT', nextCount)
}


index.jsを以下のように書き換えます。Counter.jsredux-thunkのときと同じです。


index.js

import React from 'react'

import ReactDOM from 'react-dom'
import { createStore, applyMiddleware } from 'redux'
-import thunk from 'redux-thunk'
+import createSaga from 'redux-saga'

import Counter from './Counter'
-import { nextCountAPI } from './API'
+import rootSaga from './sagas'

const initialState = {
count: 0,
isFetching: false,
}

const reducer = (state = initialState, action) => {
switch (action.type) {
case 'REQUEST_NEXT_COUNT':
return {
...state,
isFetching: true,
}
case 'RECEIVE_NEXT_COUNT':
return {
...state,
count: action.nextCount,
isFetching: false,
}
default:
return state
}
}

export const requestNextCount = () => ({
type: 'REQUEST_NEXT_COUNT',
})
export const receiveNextCount = nextCount => ({
type: 'RECEIVE_NEXT_COUNT',
nextCount,
})
-export const nextCount = () => (dispatch, getState) => {
- const { count } = getState()
-
- dispatch(requestNextCount())
- nextCountAPI(count).then(nextCount => {
- dispatch(receiveNextCount(nextCount))
- })
-}
+export const nextCount = count => ({
+ type: 'NEXT_COUNT',
+ count,
+})

+const saga = createSaga()
-const store = createStore(reducer, applyMiddleware(thunk))
+const store = createStore(reducer, applyMiddleware(saga))
+saga.run(rootSaga)

const render = () => {
ReactDOM.render(
<Counter store={store} />,
document.getElementById('root')
)
}
render()
store.subscribe(render)



まとめ

思ったよりredux-sagaが書きやすかったです。ただ、単純なアプリなら非同期処理はすべてコンポーネントに押し付けるのがシンプルで良い感じがしましたが、どうなんでしょうか。

以上、よろしくお願いします。