redux-sagaと仲良くなろう!

redux-saga

概要

はじめまして。スタメンでフロントエンドエンジニアをしている@0906kokiです。筋トレを週5でする筋肉エンジニアです。 普段はReactとTypeScriptで開発をしていますが、サーバーサイドのRailsAPIを作成するタイミングで触ります。

弊社のプロダクトであるTUNAGでは、フロントエンドをReact、Reduxで実装しており、Reduxにredux-sagaを導入しています。今回は、そのredux-sagaについて書きたいと思います。

※ 本記事はReactとReduxをある程度理解している人向けとなっております。

経緯

Reduxを触っていると、非同期処理などの副作用を、redux-thunkやredux-sagaなどのmiddlewareが受け持つことが多いと思います。また、Reduxを使わずに、Reactライフサイクル内で非同期処理を記述することもあると思います。

Redux有り無しに関わらず、非同期処理によってコードが複雑化するケースが多く、非同期処理をシンプルに保つことはReactの実装においては重要な課題です。そうした課題に対して、redux-sagaは他の手法に比べて、コードをシンプルに保つことができるので、今回は改めてredux-sagaについて学習してみようと思いました。

主なフレームワークとライブラリ

  • react(16.3.2)
  • react-redux(7.1.0)
  • redux-saga(1.0.5)

redux-sagaの基本

redux-sagaは、reduxの同期的なフローの中に、非同期処理などの副作用を簡単に組み込めるようにするライブラリです。また、redux-sagaでは、独自のタスクという概念によって、副作用を並列で走らせることができます。 こうした副作用の処理を、同期的なreduxフローのどこで行うかというと、middlewareが担当します。 大まかなreduxの流れに、redux-sagaを入れるとこんな感じになります。

  1. クリックなどのユーザーイベントにより、ActionをDispatchする
  2. そのActionをredux-sagaが検知して、タスクを実行
  3. そのタスク内での非同期処理が成功した場合、成功の結果を通知するActionをDispatchする

通常は、ActionをDispatch → Reducerでstateを更新の流れなのですが、redux-sagaを使用すると、特定のActionがDispatchされたタイミングで、そのアクションに対応したジェネレータ関数を実行します。更に、その関数の中でAPI callを行い、ActionをDispatchすることで、APIで受け取ったデータをもとに、stateに更新をかけることもできます。

redux-sagaが提供するタスク実行コマンド

redux-sagaでは、タスクを実行するコマンドを、redux-saga/effectsのimportによって使用できます。主なコマンドは以下の通りです。

  • put(ActionをDispatchする)
  • take(指定のActionのDispatchの監視を行う)
  • takeEvery(同じActionが複数回来た場合に、並列で処理を行う)
  • takeLatest(処理をキャンセルし、新しい処理を行う)
  • call(非同期処理を呼び出して、終了を待つ)
  • fork(タスクの実行を行う)
  • select(storeからstateを取り出す)

こうしたコマンドを、ジェネレーター関数の中で使用して、同期的に記述することを可能にします。

redux-sagaのメリット

reduxの中で非同期処理などの副作用を受け持つライブラリとして、redux-thunkがあり、よくredux-sagaと対比されるシーンが多く見受けられます。 redux-sagaでは、redux-thunkと比較して、どういったメリットがあるんでしょうか? redux-sagaを導入することのメリットとしては、主に以下のことが挙げられると思います。

  1. redux-thunkはAction Createrに処理を記述する一方、redux-sagaはsagaコンポーネントに記述できるため、複雑化しにくい
  2. テストが書きやすい
  3. Async/Awaitなどの非同期処理のネストが深くならずに済む

redux-sagaをreduxの中で使用する

基本的なredux-sagaの使い方が分かった所で、実際にreduxの中でredux-sagaを使用してみたいと思います。まずは、redux-sagaをインストールします。

npm install --save redux-saga

タスクを作成

今回は、データをサーバーからフェッチしてきて、成功したら成功のアクションをDispatch、失敗したら失敗のアクションをDispatchする処理をSaga内で書いていきたいと思います。 Sagaで使用するアクションとして、データのリクエストを行うREQUEST_FETCH_DATA、データ取得が成功した場合のSUCCESS_FETCH_DATA、データ取得が失敗した場合のFAILURE_FETCH_DATAを定義します。

その次に、src/sagasディレクトリを作成し、配下にsample.jsファイルを作成します。そのファイルに以下のように記述します。

import { call, select, fork, take, takeEvery, put } from 'redux-saga/effects'
import fetchData from '../apis'
import { REQUEST_FETCH_DATA, SUCCESS_FETCH_DATA, FAILURE_FETCH_DATA } from '../actions/actionTypes'
import { successFetchData, failureFetchData } from '../actions'

function* runRequestFetchData() {
 const { text } = yield select((state) => state)
 const { payload, error } = yield call(fetchData, text)
 
 if (payload && !error) {
   yield put(successFetchData(payload))
 } else {
   yield put(failureFetchData(error))
 }
}

ここでは以下のような処理を行っています。

  1. selectでstoreにあるtextのstateを取得
  2. このstateをfetchDataの引数に入れて、callによって非同期処理
  3. 取得が成功していれば、successFetchDataアクションをDispatch
  4. 取得に失敗していれば、failureFetchDataアクションをDispatch

上記のようにrunRequestFetchDataというタスクを用意しましたが、このタスクはどうやって実行されるのでしょうか? このタスクはREQUEST_FETCH_DATAアクションがDispatchされたタイミングで実行したいので、Actionを監視するように処理を記述しなければいけません。監視するには以下のようにタスクを追加します。

// 省略

function* handleRequestFetchData() {
 yield takeEvery(REQUEST_FETCH_DATA, runRequestFetchData)
}

これだけで、ActionがDispatchされたタイミングで、runRequestFetchDataを実行することができます。簡単ですね。

※並列で非同期処理を走らせる場合

非同期処理を並列で実行して、データを取得したい場合は、以下のように書きます。

const { firstData, secondData } = yield all([ call(fetchData1, text1), call(fetchData2, text2)  ])

call allを使用した並列処理の場合、Promise.allと同様に、すべての処理がresolveされるまでか、どちらかがrejectされるまで、値を返しません。

redux-sagaでのテスト

redux-sagaでは、テストの書きやすさをメリットとして挙げましたが、どうやってテストを書くのでしょうか? いくつかredux-sagaのテスト用ライブラリは存在しますが、今回は一番スター数の多いredux-saga-test-planを使ったUnitテストを、先程のコードを交えて紹介します。

import { expextSaga } from 'redux-saga-test-plan'

describe('runRequestFetchData', () => {
  test('success', () => {
    const successResponse = {
      payload: 'some data'
    };
    
    return expectSaga(runRequestFetchData)
    .provide([
    [call.fn(fetchData, 'text'), successResponse]
    ])
    .put({
      type: 'SUCCESS_FETCH_DATA',
      payload: { payload: successResponse.payload }
    })
    .run();
  });
  
  test('failure', () => {
    const failureResponse = {
      error: 'エラー'
    };
    
    return expectSaga(runRequestFetchData)
    .provide([
      [call.fn(fetchData, 'text'), failureResponse]
    ])
    .put({
      type: 'FAILURE_FETCH_DATA',
      error: failureResponse.error
    })
    .run()
  })
}) 

上記の例では、runRequestFetchDataタスクを実行したときに、putで最終的に返却されるデータと一致しているかテストしています。 redux-saga-test-planを使ったテストでは、callで呼び出すfetch関数と、その関数を実行した場合の返却データをProviderを使用することで、予めセットしておくことができます。上記の例では、成功した場合と失敗した場合のリスポンスデータを、successResponsefailureResponseにセットしています。 そのデータを元に、FAILURE_FETCH_DATAアクションとFAILURE_FETCH_DATAアクションをDispatchしています。

storeの接続

それでは、最後にreduxとredux-sagaをつなぎこむ実装をしたいと思います。以下のようにすべてのSagaを集約するrootSagaファイルを作成し、rootSaga関数でタスクをまとめます。

// 省略
export default function* sampleSaga() {
  yield fork(handleRequestFetchData)
}
import { fork } from 'redux-saga/effects'
import sampleSaga from './sampleSaga'

export default function* rootSaga() {
  yield fork(sampleSaga)
}

rootSagaが定義されたので、このrootSagaをstoreに接続して、タスクを実行できるようにします。

import { createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'

// sagaMiddlewareを作成
const sagaMiddleware = createSagaMiddleware()
// ストアを作成
const store = createStore(reducer, applyMiddleware(sagaMiddleware, someMiddleware))

以上で、Sagaの一連の実装は完成しました。とても処理の流れを追うのが簡単だと思います。 redux-sagaを使用すると、redux-thunkのコールバック地獄や、Reactライフサイクル内での非同期処理の煩雑性から開放されるので、是非使ってみてください。

最後に

今回の記事ではredux-sagaについて書きました。redux-sagaは特徴的な書き方なので、とっつきにくい部分はありますが、今回の記事で少しでもredux-sagaに詳しくなれたら幸いです。

スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています!興味を持ってくれた方は、ぜひ下記のエンジニア採用サイトをご覧ください。