メインコンテンツにスキップ

初心者向けチュートリアル

このチュートリアルの目的

このチュートリアルでは、(うまくいけば)わかりやすい方法でredux-sagaを紹介します。

入門チュートリアルでは、Reduxリポジトリの簡単なカウンターデモを使用します。このアプリケーションは非常に基本的なものですが、詳細に埋もれることなくredux-sagaの基本概念を示すのに適しています。

初期設定

始める前に、チュートリアルリポジトリをクローンしてください。

このチュートリアルの最終コードは、sagasブランチにあります。

次に、コマンドラインで次を実行します

$ cd redux-saga-beginner-tutorial
$ npm install

アプリケーションを起動するには、次を実行します

$ npm start

コンパイルが完了したら、ブラウザでhttp://localhost:9966を開きます。

最も基本的なユースケースから始めます。カウンターをインクリメントおよびデクリメントするための2つのボタンです。後で、非同期呼び出しを紹介します。

うまくいけば、インクリメントデクリメントの2つのボタンと、下にカウンター: 0と表示されるはずです。

アプリケーションの実行で問題が発生した場合は、チュートリアルリポジトリで遠慮なくissueを作成してください。

Hello Sagas!

最初のSagaを作成します。伝統に従い、Sagaの「Hello, world」バージョンを作成します。

ファイルsagas.jsを作成し、次のスニペットを追加します

export function* helloSaga() {
console.log('Hello Sagas!')
}

それほど怖いことはありません。通常の関数(*を除く)です。コンソールに挨拶メッセージを表示するだけです。

Sagaを実行するには、以下を行う必要があります。

  • 実行するSagaのリストを含むSagaミドルウェアを作成します(今のところ、helloSagaが1つだけです)。
  • SagaミドルウェアをReduxストアに接続します。

main.jsを変更します。

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

// ...
import { helloSaga } from './sagas'

const sagaMiddleware = createSagaMiddleware()
const store = createStore(
reducer,
applyMiddleware(sagaMiddleware)
)
sagaMiddleware.run(helloSaga)

const action = type => store.dispatch({type})

// rest unchanged

まず、./sagasモジュールからSagaをインポートします。次に、redux-sagaライブラリによってエクスポートされたファクトリ関数createSagaMiddlewareを使用してミドルウェアを作成します。

helloSagaを実行する前に、applyMiddlewareを使用してミドルウェアをストアに接続する必要があります。次に、sagaMiddleware.run(helloSaga)を使用してSagaを開始できます。

今のところ、Sagaは特別なことは何もしていません。メッセージをログに記録して終了するだけです。

非同期呼び出しを行う

次に、元のカウンターデモに近いものを追加しましょう。非同期呼び出しを説明するために、クリックから1秒後にカウンターをインクリメントする別のボタンを追加します。

まず、UIコンポーネントに追加のボタンとコールバックonIncrementAsyncを提供します。

Counter.jsを変更します。

const Counter = ({ value, onIncrement, onDecrement, onIncrementAsync }) =>
<div>
<button onClick={onIncrementAsync}>
Increment after 1 second
</button>
{' '}
<button onClick={onIncrement}>
Increment
</button>
{' '}
<button onClick={onDecrement}>
Decrement
</button>
<hr />
<div>
Clicked: {value} times
</div>
</div>

次に、コンポーネントのonIncrementAsyncをストアアクションに接続する必要があります。

main.jsモジュールを次のように変更します

function render() {
ReactDOM.render(
<Counter
value={store.getState()}
onIncrement={() => action('INCREMENT')}
onDecrement={() => action('DECREMENT')}
onIncrementAsync={() => action('INCREMENT_ASYNC')} />,
document.getElementById('root')
)
}

redux-thunkとは異なり、コンポーネントはプレーンなオブジェクトアクションをディスパッチすることに注意してください。

次に、非同期呼び出しを実行する別のSagaを紹介します。ユースケースは次のとおりです。

INCREMENT_ASYNCアクションごとに、次のタスクを開始します。

  • 1秒待ってからカウンターをインクリメントします

次のコードをsagas.jsモジュールに追加します

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

const delay = (ms) => new Promise(res => setTimeout(res, ms))

// ...

// Our worker Saga: will perform the async increment task
export function* incrementAsync() {
yield delay(1000)
yield put({ type: 'INCREMENT' })
}

// Our watcher Saga: spawn a new incrementAsync task on each INCREMENT_ASYNC
export function* watchIncrementAsync() {
yield takeEvery('INCREMENT_ASYNC', incrementAsync)
}

説明の時間です。

指定されたミリ秒後に解決されるPromiseを返すdelay関数を作成します。この関数を使用して、ジェネレーターを*ブロック*します。

Sagaは、redux-sagaミドルウェアにオブジェクトを*yield*するジェネレーター関数として実装されます。yieldされたオブジェクトは、ミドルウェアによって解釈される一種の命令です。Promiseがミドルウェアにyieldされると、ミドルウェアはPromiseが完了するまでSagaを中断します。上記の例では、incrementAsync Sagaは、delayによって返されたPromiseが解決されるまで中断されます。これは1秒後に発生します。

Promiseが解決されると、ミドルウェアはSagaを再開し、次のyieldまでコードを実行します。この例では、次のステートメントは、put({type: 'INCREMENT'})を呼び出した結果である別のyieldオブジェクトです。これは、ミドルウェアにINCREMENTアクションをディスパッチするように指示します。

putは、*エフェクト*と呼ばれるものの1つの例です。エフェクトは、ミドルウェアによって満たされる命令を含むプレーンなJavaScriptオブジェクトです。ミドルウェアがSagaによってyieldされたエフェクトを取得すると、エフェクトが満たされるまでSagaは一時停止されます。

つまり、incrementAsync Sagaは、delay(1000)の呼び出しによって1秒間スリープし、次にINCREMENTアクションをディスパッチします。

次に、別のSaga watchIncrementAsyncを作成しました。redux-sagaによって提供されるヘルパー関数であるtakeEveryを使用して、ディスパッチされたINCREMENT_ASYNCアクションをリッスンし、毎回incrementAsyncを実行します。

これで2つのSagaができたので、両方を一度に開始する必要があります。そのためには、他のSagaを開始する役割を果たすrootSagaを追加します。同じファイルsagas.jsで、次のようにファイルをリファクタリングします

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

export const delay = (ms) => new Promise(res => setTimeout(res, ms))

export function* helloSaga() {
console.log('Hello Sagas!')
}

export function* incrementAsync() {
yield delay(1000)
yield put({ type: 'INCREMENT' })
}

export function* watchIncrementAsync() {
yield takeEvery('INCREMENT_ASYNC', incrementAsync)
}

// notice how we now only export the rootSaga
// single entry point to start all Sagas at once
export default function* rootSaga() {
yield all([
helloSaga(),
watchIncrementAsync()
])
}

このSagaは、2つのSagaであるhelloSagawatchIncrementAsyncを呼び出した結果の配列をyieldします。これは、結果の2つのジェネレーターが並行して開始されることを意味します。これで、main.jsのルートSagaでsagaMiddleware.runを呼び出すだけです。

// ...
import rootSaga from './sagas'

const sagaMiddleware = createSagaMiddleware()
const store = ...
sagaMiddleware.run(rootSaga)

// ...

コードをテスト可能にする

incrementAsync Sagaをテストして、目的のタスクを実行していることを確認します。

別のファイルsagas.spec.jsを作成します

import test from 'tape'

import { incrementAsync } from './sagas'

test('incrementAsync Saga test', (assert) => {
const gen = incrementAsync()

// now what ?
})

incrementAsyncはジェネレーター関数です。実行すると、イテレーターオブジェクトが返されます。イテレーターのnextメソッドは、次の形状のオブジェクトを返します。

gen.next() // => { done: boolean, value: any }

valueフィールドには、yieldされた式、つまりyield後の式の結果が含まれています。doneフィールドは、ジェネレーターが終了したか、まだ「yield」式があるかを示します。

incrementAsyncの場合、ジェネレーターは2つの値を連続してyieldします。

  1. yield delay(1000)
  2. yield put({type: 'INCREMENT'})

したがって、ジェネレーターのnextメソッドを連続して3回呼び出すと、次の結果が得られます。

gen.next() // => { done: false, value: <result of calling delay(1000)> }
gen.next() // => { done: false, value: <result of calling put({type: 'INCREMENT'})> }
gen.next() // => { done: true, value: undefined }

最初の2回の呼び出しは、yield式の結果を返します。3回目の呼び出しでは、それ以上のyieldがないため、doneフィールドがtrueに設定されます。また、incrementAsyncジェネレーターは何も返さないため(returnステートメントがない)、valueフィールドはundefinedに設定されます。

したがって、incrementAsync内のロジックをテストするには、返されたジェネレーターを反復処理し、ジェネレーターによってyieldされた値をチェックする必要があります。

import test from 'tape'

import { incrementAsync } from './sagas'

test('incrementAsync Saga test', (assert) => {
const gen = incrementAsync()

assert.deepEqual(
gen.next(),
{ done: false, value: ??? },
'incrementAsync should return a Promise that will resolve after 1 second'
)
})

問題は、delayの戻り値をどのようにテストするかです。Promiseで単純な等価性テストを行うことはできません。delayが*通常の*値を返した場合、テストは簡単になったでしょう。

さて、redux-sagaは、上記のステートメントを可能にする方法を提供します。incrementAsync内でdelay(1000)を直接呼び出す代わりに、*間接的に*呼び出し、後続の深い比較を可能にするためにエクスポートします。

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

export const delay = (ms) => new Promise(res => setTimeout(res, ms))

// ...

export function* incrementAsync() {
// use the call Effect
yield call(delay, 1000)
yield put({ type: 'INCREMENT' })
}

yield delay(1000)を実行する代わりに、yield call(delay, 1000)を実行しています。違いは何ですか?

最初の場合、yield式delay(1000)は、nextの呼び出し元に渡される前に評価されます(呼び出し元は、コードを実行するときはミドルウェアになる可能性があります。ジェネレーター関数を実行し、返されたジェネレーターを反復処理するテストコードになる可能性もあります)。したがって、呼び出し元が取得するのは、上記のテストコードのようにPromiseです。

2番目の場合、yield式call(delay, 1000)は、nextの呼び出し元に渡されるものです。callは、putと同様に、指定された引数を使用して指定された関数を呼び出すようにミドルウェアに指示するエフェクトを返します。実際、putcallも、それ自体でディスパッチまたは非同期呼び出しを実行しません。それらはプレーンなJavaScriptオブジェクトを返します。

put({type: 'INCREMENT'}) // => { PUT: {type: 'INCREMENT'} }
call(delay, 1000) // => { CALL: {fn: delay, args: [1000]}}

何が起こるかというと、ミドルウェアはyieldされた各エフェクトのタイプを調べ、そのエフェクトを満たす方法を決定します。エフェクトタイプがPUTの場合、ストアにアクションをディスパッチします。エフェクトがCALLの場合、指定された関数を呼び出します。

エフェクトの作成とエフェクトの実行のこの分離により、ジェネレーターを驚くほど簡単な方法でテストすることが可能になります。

import test from 'tape'

import { put, call } from 'redux-saga/effects'
import { incrementAsync, delay } from './sagas'

test('incrementAsync Saga test', (assert) => {
const gen = incrementAsync()

assert.deepEqual(
gen.next().value,
call(delay, 1000),
'incrementAsync Saga must call delay(1000)'
)

assert.deepEqual(
gen.next().value,
put({type: 'INCREMENT'}),
'incrementAsync Saga must dispatch an INCREMENT action'
)

assert.deepEqual(
gen.next(),
{ done: true, value: undefined },
'incrementAsync Saga must be done'
)

assert.end()
})

putcallはプレーンなオブジェクトを返すため、テストコードで同じ関数を再利用できます。また、incrementAsyncのロジックをテストするには、ジェネレーターを反復処理し、その値でdeepEqualテストを行います。

上記のテストを実行するには、次を実行します

$ npm test

これにより、コンソールに結果が報告されるはずです。