8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

typescript-fsa-redux-sagaことはじめ

Last updated at Posted at 2020-01-13

typescript-fsaしゅごい :sunflower:

そんな便利なtypescript-fsaには以下のCompanion Packagesが同じ作者より提供されています(リンク省略)

  • typescript-fsa-redux-saga
  • typescript-fsa-redux-observable
  • typescript-fsa-redux-thunk
  • typescript-fsa-reducers

この記事ではtypescript-fsa-redux-sagaの使い方について簡単にまとめた記事になります(一部typescript-fsa及びtypescript-fsa-reducersが入ります)。

本記事執筆時におけるバージョンは以下の通りです。

redux@4.0.5
redux-saga@1.1.3
typescript-fsa@3.0.0
typescript-fsa-reducers@1.2.1
typescript-fsa-redux-saga@2.0.0

提供されているAPI :hugging:

提供されているAPIはreadmeにも書かれていますがbindAsyncActionのみです。

bindAsyncActionAsyncActionCreatorを受け取ってHigherOrderSagaを返す関数です。

以下のような処理を行います。

  1. AACを受け取る
  2. 受け取ったSagaIteratorに引数paramsを渡して起動
  3. put(AAC.started({params}))(省略可)
  4. 受け取ったSagaIteratorが処理を終えreturn/throwすると
    • returnの場合、yieldしたresultをput(AAC.done({params, result}))
    • throwの場合、catchthrowされたerrorを使ってput(AAC.failed({params, error}))

AsyncACtionCreator

AsyncActionCreator(AAC)はtypescript-fsaactionCreator#asyncで作られるオブジェクトです。
作り方等は私の記事で恐縮ですが:poop:こちら:poop:を参照ください。

HigherOrderSaga

HigherOrderSagaSagaIteratorを受け取る関数です。

SagaIteratorredux-sagaにて以下のように定義されています。

export type SagaIterator<RT = any> = Iterator<StrictEffect, RT, any>

Iteratorの定義も見てみます。

interface Iterator<T, TReturn = any, TNext = undefined> {
    // NOTE: 'next' is defined using a tuple to ensure we report the correct assignability errors in all places.
    next(...args: [] | [TNext]): IteratorResult<T, TReturn>;
    return?(value?: TReturn): IteratorResult<T, TReturn>;
    throw?(e?: any): IteratorResult<T, TReturn>;
}

つまりnext()した際にIteratorYieldResult<StrictEffect>が返ってくるIterableオブジェクトが来れば良さそうです。

次にStrictEffectについて見てみます。

export type StrictEffect<T = any, P = any> = SimpleEffect<T, P> | StrictCombinatorEffect<T, P>;

export interface SimpleEffect<T, P = any> extends Effect<T, P> {
  combinator: false
}
export interface StrictCombinatorEffect<T, P> extends Effect<
  T, CombinatorEffectDescriptor<StrictEffect>
> {
  combinator: true
}

export interface Effect<T = any, P = any> {
  '@@redux-saga/IO': true
  combinator: boolean
  type: T
  payload: P
}

ここから以下のことが分かりました

  • StrictEffectSimpleEffectStrictBombinatorEffectの共用体型ということ
  • SimpleEffectStrictBombinatorEffectEffectを継承しているということ
  • EffectはFSAを拡張したものということ

使ってみる :punch:

型パワーを実感するために使ってみます。

1秒かけてstringの文字数を返すasync関数をsagaで動かすことを考えてみます。また与えられた文字数が3文字の場合Errorthrowします。

まずはAACとそのasync関数を定義します。

// いつもの
import actionCreatorFactory from 'typescript-fsa'
const actionCreator = actionCreatorFacory()

// AAC定義
const getLengthAction = actionCreator.async<string, number, Error>('getLengthAction')

// async関数
const getLength = async (s: string): Promise<number> => {
  await new Promise(resolve => globalThis.setTimout(resolve, 1000))
  if (s.length !== 3) {
    return s.length
  } else {
    throw new Error('error')
  }
}

次にsaga workerを定義します。

typescript-fsa-redux-sagaを使わない場合は似たような処理を以下のように書くと思います。

function* getLengthWorker(s: string) {
  try {
    const result = yield call(getLength, s)
    yield put({type: 'SomeActionDone', payload: result})
  } catch (e) {
    yield put({type: 'SomeActionFailed', payload: e, error: true})
  }
}

typescript-fsa-redux-sagaを使う場合は以下のように書きます。

// saga worker
function* getLengthWorker(s: string) {
  return yield call(getLength, s)
}

// saga workerをwrapしたworker
const boundGetLengthWorker = bindAsyncAction(getLengthAction)(getLengthWorker)

処理結果をreturnしていないことや例外をcatchしていないのでだいぶシンプルになっていると思います。

参考までに、getLengthWorkerSimpleEffectを継承したCallEffectを返すcallyieldし、その結果をreturnしています。これによりIterator<SimpleEffect>のシグネチャを満たしています。

なので

function* getLengthWorker(s: string) {
  // return yield call(getLength, s)
  return yield s
}

のようにgetLengthWorkerを書き換えるとgetLengthWorkerの型はIterator<string>になりbindAsyncActionのシグネチャを満たさずコンパイルエラーになります。

次にboundGetLengthWorkerの起動方法を見てみます。

function* getLengthHandler(): SagaIterator {
  yield takeEvery(getLengthAction.started, function*({payload}) {
    yield call(boundGetLengthWorker, payload)
  })
}

export function* rootSaga(): SagaIterator {
  yield fork(getLengthHandler)
}

getLengthHandlergetLengthAction.startedを受け取るとboundGetLengthWorkerを起動します。
payloadgetLengthAction.startedのpayloadの型なのでここでの型チェックを行うことができます。

以上が大まかな流れです。

注意点 :warning:

AAC.startedの罠 :hole:

さて、上記のサンプルには致命的なミスがあります。getLengthHandlergetLengthAction.startedを受け取りboundGetLengthWorkerを起動していますが、そのboundGetLengthWorker内部でもgetLengthAction.startedを発火させています。
そのためこのまま実行すると無限ループに陥ってしまいます。 :scream:

これを回避するためには以下の2つの手段が取り得ます。

  • saga workerでput(getLengthAction.started())しない
  • takeEveryで受け取るActionを変える

saga workerでput(getLengthAction.started())しない :skier:

この方法は非常にスッキリ書けます。以下のようにbindAsyncActionにoptionを渡すだけです。

const boundGetLengthWorker = bindAsyncAction(getLengthAction, {skipStartedAction: true})(getLengthWorker)

takeEveryで受け取るActionを変える :snowboarder:

この方法は書き方が冗長になりますがもう少し自由度をもたせることが可能です。
以下のようにsaga起動用のactionを別に定義します。

// AAC定義
type getLengthProp = string
const getLengthAction = actionCreator.async<getLengthProp, number, Error>('getLengthAction')

// trigger用Action
const getLengthOp = actionCreator<getLengthProp>('GetLengthOp')

// ...

function* getLengthHandler(): SagaIterator {
  yield takeEvery(hogeAsyncOp, function*({payload}) {
    yield call(boundGetLengthWorker, payload)
  })
}

このようにすることで例えばtakeEveryした後様々な前処理を行い、その後実行されるboundGetLengthWorkerが起動したタイミングを正確にputしたいというニーズを満たすことができます(すいません、このシチュエーションは相当なレベルのコーナーケースだと思います。他に良い例があれば教えてください・・・)。

なおこの場合でも仮にgetLengthActionのPropsの型とgetLengthOpのPropsの型が異なった場合getLengthHandlerにて型エラーを検知できます。

個人的には特段の事情が無い限りは{skipStartedAction: true}してAAC.starteddispatchするのが良いと思います。

AAC.failedは型安全ではない :unlock:

実際に使用する際はtypescript-fsa-reducersを合わせて以下のように使うと思います。

import { reducerWithInitialState } from 'typescript-fsa-reducers'
import { ErrorState } from '../containers/Error'

const failedHandler = (_: unknown, { error }: { error: Error }): ErrorState => {
  return {
    error: { type: error.name, message: error.message },
  }
}

export const errorReducer = reducerWithInitialState({
  error: null,
} as ErrorState)
  .case(getLengthAction.failed, failedHandler)

reducer内では型安全が保証されています。getLengthAction.failedのpayloadは{ error: Error }が含まれています。これは一見問題なさそうです。

しかし例えばgetLengthActionでbindされたsaga workerでcallしているasync関数(getLength)が以下のようになっている場合どうなるでしょうか。

const getLength = async (s: string): Promise<number> => {
  throw 'a'
}

この場合は型エラーが出ません。javascriptでは全てのオブジェクトがthrowableなためです。
そのため丁寧にthrowしないと実行時エラーが起きてしまう危険があります。

終わりに :notebook:

以上を元にtypescript-fsa-redux-sagaについて簡単にまとめてみます。

  • メリット
    • try...catch...のようなボイラープレートを削減できる
    • 型チェックできる部分が増える
  • デメリット
    • saga worker内でthrowしないといけない

個人的な所感ですがthrowする事を許容する/しないという点が採用にあたってのポイントになると思います。
特にsaga内でcallする非同期関数で謎のerrorをthrowされる危険を許容できないのであればtypescript-fsa-redux-sagaを採用せずtry...catch...で手動でAACをputするのも有りだと思います。

また、現状bindAsyncActionthrowされた時のみAAC.failedputするという点も覚えておきたいところです。

8
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?