Help us understand the problem. What is going on with this article?

ReactとReduxで非同期処理をやってみる【Redux-saga】

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の設定を行います。

index.tsx
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のエイリアスに設定しています。

reducers/index.ts
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の中身はこのあと作っていきます。

index.ts
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点に修正を加えます。

  1. actions/example.ts
    (上に書いたsrc/index.tsxと同じディレクトリ内に作る。reducerssagas共同じディレクトリ)
  2. reducers/example.ts
  3. components/App.tsx
    (上に書いたsrc/index.tsxでimportしているやつ)
actions/example.ts
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>
reducers/example.ts
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
App.tsx
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/examplegetData.started)を経て、
exampleReducerType. STARTED_GET_DATAからステイトが返却されます。

ですがこのままではAPIへのリクエストを出していないので、reducers/example.tsinitialState.startedGetDatatrueに変化するだけとなってしまいます。
(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に下記のファイルを追加する。

sagas/example.ts
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で非同期実行する内容です。

apis/example.ts
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.tsgetDataSagaの処理は
yield put(getFiles.succeeded(result))まで進み、
reducers/example.tsSUCCEEDED_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
y4u0t2a1r0
React, TypeScript, AWS
pa-rk
Webアプリ、スマホアプリの開発を手掛ける技術者集団です。
https://www.pa-rk.co.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away