Use promises in redux sagas with TypeScript-first approach
This projects aims to create promise actions (where each action has its deferred promise after surpassing the middleware) that are able to be fullfilled or rejected in redux-saga-manner.
Initially forked from @adobe/redux-saga-promise but completelly revamped to use createAction
from @reduxjs/toolkit
to support TypeScript.
- Overview
- Installation
- Promise middleware integration
- Promise action creation
- Promise action await
- Promise action fulfillment or rejection
- Promise action's reducable lifecycle actions
- Promise action caveats in sagas
- Argument validation
- TypeScript helpers
- Contributing
- Licensing
- Redux middlware, namely
promiseMiddleware
, used to transform a promise-action-marked action (not promise action yet) to a fully qualified promise action that owns a deferred promise to allow a deferred fulfillment or rejection. - Saga helpers namely
implementPromiseAction()
to resolve and reject by passing either a generator function, an asnyc function or simply a function where the return value is used to fulfill the deferred promise inside the promise action,resolvePromiseAction()
to resolve-only the deferred promise inside the promise action andrejectPromiseAction()
to reject-only the deferred promise inside the promise action.
- Lifecyle actions namely
promiseAction.trigger
(same instance likepromiseAction
) created by you (viapromiseAction()
) and dispatched against the redux store,promiseAction.resolved
created after the deferred promise has been resolved andpromiseAction.rejected
created after the deferred promise has been rejected- can be used in reducers or wherever you would need these actions.
- TypeScript helper types
npm install @teroneko/redux-saga-promise
You must include include promiseMiddleware
in the middleware chain, and it must come before sagaMiddleware
:
import { applyMiddleware, createStore } from "redux"
import { promiseMiddleware } from "@teroneko/redux-saga-promise"
import createSagaMiddleware from "redux-saga"
// ...assuming rootReducer and rootSaga are defined
const sagaMiddleware = createSagaMiddleware()
const store = createStore(rootReducer, {}, applyMiddleware(promiseMiddleware, sagaMiddleware))
sagaMiddleware.run(rootSaga)
Use one of following method to create a promise action.
Create a promise action (creator) with action-type.
import { promiseActionFactory } from "@teroneko/redux-saga-promise"
// creates a promise action with only a type (string)
const actionCreator = promiseActionFactory<resolve_value_type_for_promise>().create(type_as_string)
const action = promiseActionFactory<resolve_value_type_for_promise>().create(type_as_string)()
// equivalent to
const actionCreator = createAction(type_as_string)
const action = createAction(type_as_string)()
Create a promise action (creator) with action-type and payload.
import { promiseActionFactory } from "@teroneko/redux-saga-promise"
// creates a promise action with type (string) and payload (any)
const actionCreator = promiseActionFactory<resolve_value_type_for_promise>().create<payload_type>(type_as_string)
const action = promiseActionFactory<resolve_value_type_for_promise>().create<payload_type>(type_as_string)({} as payload_type) // "as payload_type" just to show intention
// equivalent to
const actionCreator = createAction<payload_type>(type_as_string)
const action = createAction<payload_type>(type_as_string)({} as payload_type) // "as payload_type" just to show intention
Create a promise action (creator) with a action-type and an individial payload (creator).
import { promiseActionFactory } from "@teroneko/redux-saga-promise"
// creates a promise action with type (string) and payload creator (function)
const actionCreator = promiseActionFactory<resolve_value_type_for_promise>().create(type_as_string, (payload: payload_type) => { payload })
const action = promiseActionFactory<resolve_value_type_for_promise>().create(type_as_string, (payload: payload_type) => { payload })({} as payload_type) // "as payload_type" just to show intention
// equivalent to
const actionCreator = createAction(type_as_string, (payload: payload_type) => { payload })
const action = createAction(type_as_string, (payload: payload_type) => { payload })({} as payload_type) // "as payload_type" just to show intention
Await a promise action after it has been dispatched towards the redux store.
⚠️ Keep in mind that the action is not awaitable after its creation but as soon it surpassed the middleware!
// Internally all members of the promiseAction (without
// promise capabilities) gets assigned to the promise.
const promise = store.dispatch(promiseAction());
const resolvedValue = await promise;
Feel free to use then
, catch
or finally
on promise
.
Either you use
implementPromiseAction(action, function)
to resolve and reject by passing either a generator function, an asnyc function or simply a function where the return value is used to fulfill the deferred promise,resolvePromiseAction(action, value)
to resolve-only the deferred promise inside the promise action orrejectPromiseAction(action, value)
to reject-only the deferred promise inside the promise action,
because it is up to you as the implementer to resolve or reject the promise"s action in a saga.
The most convenient way! You give this helper a saga function which it will execute. If the saga function succesfully returns a value, the promise will resolve with that value. If the saga function throws an error, the promise will be rejected with that error. For example:
import { call, takeEvery } from "redux-saga/effects"
import { promises as fsPromises } from "fs"
import { implementPromiseAction } from "@teroneko/redux-saga-promise"
import promiseAction from "./promiseAction"
//
// Asynchronously read a file, resolving the promise with the file"s
// contents, or rejecting the promise if the file can"t be read.
//
function * handlePromiseAction (action) {
yield call(implementPromiseAction, action, function * () {
//
// Implemented as a simple wrapper around fsPromises.readFile.
// Rejection happens implicilty if fsPromises.readFile fails.
//
const { path } = action.payload
return yield call(fsPromises.readFile, path, { encoding: "utf8" })
})
}
export function * rootSaga () {
yield takeEvery(promiseAction, handlePromiseAction)
})
If you call implementPromiseAction()
with a first argument that is not a
promise action, it will throw an error (see Argument Validation below).
Sometimes you may want finer control, or want to be more explicit when you know an operation won"t fail. This helper causes the promise to resolve with the passed value. For example:
import { call, delay, takeEvery } from "redux-saga/effects"
import { resolvePromiseAction } from "@teroneko/redux-saga-promise"
import promiseAction from "./promiseAction"
//
// Delay a given number of seconds then resolve with the given value.
//
function * handlePromiseAction (action) {
const { seconds, value } = action.payload
yield delay(seconds*1000)
yield call(resolvePromiseAction, action, value)
}
function * rootSaga () {
yield takeEvery(promiseAction, handlePromiseAction)
})
If you call resolvePromiseAction()
with a first argument that is not a
promise action, it will throw an error (see Argument Validation below).
Sometimes you may want finer control, or want to explicitly fail without needing to throw
. This helper causes the promise to reject with the
passed value, which typically should be an Error
. For example:
import { call, takeEvery } from "redux-saga/effects"
import { rejectPromiseAction } from "@teroneko/redux-saga-promise"
import promiseAction from "./promiseAction"
//
// TODO: Implement this! Failing for now
//
function * handlePromiseAction (action) {
yield call(rejectPromiseAction, action, new Error("Sorry, promiseAction is not implemented yet")
}
function * rootSaga () {
yield takeEvery(promiseAction, handlePromiseAction)
})
If you call rejectPromiseAction()
with a first argument that is not a
promise action, it will throw an error (see Argument Validation below).
Commonly you want the redux store to reflect the status of a promise action: whether it"s pending, what the resolved value is, or what the rejected error is.
Behind the scenes, promiseAction = promiseActionFactory().create("MY_ACTION")
actually
creates a suite of three actions:
-
promiseAction.trigger
: An alias forpromiseAction
, which is what you dispatch that then creates the promise. -
promiseAction.resolved
: Dispatched automatically bypromiseMiddleware
when the promise is resolved; its payload is the resolved value of the promise -
promiseAction.rejected
: Dispatched automatically bypromiseMiddleware
when the promise is rejected; its payload is the rejection error of the promise
You can easily use them in handleActions
of redux-actions or createReducer
of @reduxjs/toolkit
:
import { handleActions } from "redux-actions"
import promiseAction from "./promiseAction"
//
// For the readFile wrapper described above, we can keep track of the file in the store
//
export const reducer = handleActions({
[promiseAction.trigger]: (state, { payload: { path } }) => ({ ...state, file: { path, status: "reading"} }),
[promiseAction.resolved]: (state, { payload: contents }) => ({ ...state, file: { path: state.file.path, status: "read", contents } }),
[promiseAction.rejected]: (state, { payload: error }) => ({ ...state, file: { path: state.file.path, status: "failed", error } }),
}, {})
In the sagas that perform your business logic, you may at times want to dispatch a promise action and wait for it to resolve. You can do that using redux-saga"s putResolve
Effect:
const result = yield putResolve(myPromiseAction)
This dispatches the action and waits for the promise to resolve, returning the resolved value. Or if the promise rejects it will bubble up an error.
Caution! If you use put()
instead of putResolve()
, the saga will continue execution immediately without waiting for the promise to resolve.
To avoid accidental confusion, all the helper functions validate their
arguments and will throw a custom Error
subclass ArgumentError
in case
of error. This error will be bubbled up by redux-saga as usual, and as usual you can catch it in a saga otherwise it will will bubble up to the onError
hook. If you want to, you can test the error type, e.g.:
import { applyMiddleware, compose, createStore } from "redux"
import { ArgumentError, promiseMiddleware } from "@teroneko/redux-saga-promise"
import createSagaMiddleware from "redux-saga"
// ...assuming rootReducer and rootSaga are defined
const sagaMiddleware = createSagaMiddleware({ onError: (error) => {
if (error instanceof ArgumentError) {
console.log("Oops, programmer error! I called redux-saga-promise incorrectly:", error)
} else {
// ...
}
})
const store = createStore(rootReducer, {}, compose(applyMiddleware(promiseMiddleware, sagaMiddleware)))
sagaMiddleware.run(rootSaga)
Additionally, all the helper functions will throw a custom Error
subclass ConfigurationError
if promiseMiddleware
was not properly included in the store.
promiseAction.types
does not really exist. It only exists as TypeScript-type to make use of typeof
:
const promiseAction = promiseActionFactory<number>().create("MY_ACTION");
declare const type_of_promise_returned_when_surpassing_promise_middleware: typeof promiseAction.types.promise;
declare const type_of_resolved_value_from_promise_of_promise_action: typeof promiseAction.types.resolveValue;
declare const type_of_trigger_action_that_got_created_from_the_action_creator: typeof promiseAction.types.triggerAction;
declare const type_of_resolved_action_that_got_created_from_the_action_creator: typeof promiseAction.types.resolvedAction;
declare const type_of_rejected_action_that_got_created_from_the_action_creator: typeof promiseAction.types.rejectedAction;
redux-saga
cannot infer the parameters and return type of promiseAction
correctly when using the call effect or equivalent, so you can use the pre-typed sagas:
const { implement, resolve, reject } = promiseAction.sagas;
// Instead of this...
call(implementPromiseAction, promiseAction(), () => 2);
// ... use this for better TypeScript support:
call(promiseAction.sagas.implement, promiseAction(), () => 2);
package.json
defines the following scripts:
npm build
: transpiles the source, placing the result indist/src/index.js
npm test
: builds, and then runs the test suite.
The tests are written using ts-jest
;
This project is licensed under the MIT License. See LICENSE for more information.