├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .mocharc.json ├── .prettierrc.js ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── actions.ts ├── index.ts └── reducers.ts ├── test ├── actions.test.ts └── reducers.test.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = spaces 5 | indent_size = 2 -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'omichelsen', 3 | 4 | rules: { 5 | 'prettier/prettier': ['error', require('./.prettierrc')], 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .nyc_output/ 2 | .vscode/ 3 | coverage/ 4 | lib/ 5 | node_modules/ -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": "ts-node/register", 3 | "extension": ["ts"], 4 | "reporter": "progress", 5 | "spec": "test/**/*.test.ts", 6 | "watch-files": ["src/**/*.ts", "test/**/*.test.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('eslint-config-omichelsen/.prettierrc'), 3 | semi: true, 4 | } 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | after_script: "npm install coveralls && nyc report --reporter=text-lcov | coveralls" 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [3.1.0](https://github.com/omichelsen/redux-promise-middleware-actions/compare/v3.0.1...v3.1.0) (2021-02-11) 2 | 3 | 4 | ### Features 5 | 6 | * **reducer:** add date timestamp to asyncReducer ([da762a8](https://github.com/omichelsen/redux-promise-middleware-actions/commit/da762a8d23afda6e4a235b7130fbb811f37e0309)) 7 | 8 | 9 | 10 | ## [3.0.1](https://github.com/omichelsen/redux-promise-middleware-actions/compare/v3.0.0...v3.0.1) (2019-08-24) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * **reducer:** pass arguments to async reducer type ([9ed0a13](https://github.com/omichelsen/redux-promise-middleware-actions/commit/9ed0a1324405cc4c819f778b02e96766a18c5794)) 16 | 17 | 18 | 19 | # [3.0.0](https://github.com/omichelsen/redux-promise-middleware-actions/compare/v2.2.0...v3.0.0) (2019-08-23) 20 | 21 | 22 | ### Features 23 | 24 | * **reducer:** add createReducer and better typed action creators ([91a3e28](https://github.com/omichelsen/redux-promise-middleware-actions/commit/91a3e285a2fa0e2470a805e9d17e1c339fc58025)) 25 | 26 | 27 | 28 | # [2.2.0](https://github.com/omichelsen/redux-promise-middleware-actions/compare/v2.1.0...v2.2.0) (2019-08-23) 29 | 30 | 31 | ### Features 32 | 33 | * **delimiters:** support custom type delimiters ([8467c3d](https://github.com/omichelsen/redux-promise-middleware-actions/commit/8467c3d72241f3a4065e2f6f7bf252d51c344fa2)) 34 | 35 | 36 | 37 | # [2.1.0](https://github.com/omichelsen/redux-promise-middleware-actions/compare/v2.0.0...v2.1.0) (2018-08-16) 38 | 39 | 40 | ### Bug Fixes 41 | 42 | * **test:** add rejected reason ([375b379](https://github.com/omichelsen/redux-promise-middleware-actions/commit/375b3791a853f11ac76be5b20630196a8c1c4241)) 43 | 44 | 45 | ### Features 46 | 47 | * **meta:** add metadata support ([3643edf](https://github.com/omichelsen/redux-promise-middleware-actions/commit/3643edf7fa4c40e920da20335bbc0cf617f9a78a)) 48 | 49 | 50 | 51 | # [2.0.0](https://github.com/omichelsen/redux-promise-middleware-actions/compare/b3f843d6b8c4d66359abead9544a2a73af9a1593...v2.0.0) (2018-08-16) 52 | 53 | 54 | ### Features 55 | 56 | * **ts:** use generic rest params with TS 3.0 ([b3f843d](https://github.com/omichelsen/redux-promise-middleware-actions/commit/b3f843d6b8c4d66359abead9544a2a73af9a1593)) 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Ole Michelsen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-promise-middleware-actions 2 | 3 | [![Build Status](https://img.shields.io/travis/omichelsen/redux-promise-middleware-actions/master.svg)](https://travis-ci.org/omichelsen/redux-promise-middleware-actions) 4 | [![Coverage Status](https://coveralls.io/repos/omichelsen/redux-promise-middleware-actions/badge.svg?branch=master&service=github)](https://coveralls.io/github/omichelsen/redux-promise-middleware-actions?branch=master) 5 | [![npm bundle size (minified + gzip)](https://img.shields.io/bundlephobia/minzip/redux-promise-middleware-actions.svg)](https://bundlephobia.com/result?p=redux-promise-middleware-actions) 6 | 7 | Create Redux actions with a `type` and `payload` in a standardized way. Inspired by [redux-actions](https://www.npmjs.com/package/redux-actions) but simpler and with special support for asynchronous actions (promises). 8 | 9 | Has no dependencies and is tiny (~680 bytes gzipped). First class TypeScript support. 10 | 11 | Works with [redux-promise-middleware](https://www.npmjs.com/package/redux-promise-middleware) to handle asynchronous actions by dispatching `pending`, `fulfilled` and `rejected` events based on the state of the input promise. 12 | 13 | Goals of this library: 14 | 15 | * Reference action creators directly - no need to maintain an action type enum/list 16 | * Automatically generate actions for pending, fulfilled and rejected outcomes of a promise payload 17 | * Have statically typed access to all action types - no need to manually add a type suffix like "_PENDING" 18 | * TypeScript support so asynchronous actions can't be confused for normal synchronous actions 19 | 20 | Note: If you are using TypeScript this library requires TypeScript 3. For TypeScript 2 use version 1 of this library. 21 | 22 | ## Installation 23 | 24 | You need to install this library as well as [redux-promise-middleware](https://www.npmjs.com/package/redux-promise-middleware). 25 | 26 | ``` 27 | npm install redux-promise-middleware-actions redux-promise-middleware 28 | ``` 29 | 30 | Include redux-promise-middleware when you create your store: 31 | 32 | ```js 33 | import promiseMiddleware from 'redux-promise-middleware'; 34 | 35 | composeStoreWithMiddleware = applyMiddleware( 36 | promiseMiddleware, 37 | )(createStore); 38 | ``` 39 | NOTE: This library is *not* yet compatible with the `promiseTypeSuffixes` option of `redux-promise-middleware` 40 | 41 | ## Usage 42 | 43 | ### Synchronous action 44 | 45 | Synchronous actions works exactly like redux-actions. You supply a function that returns whatever payload the action should have (if any). 46 | 47 | ```js 48 | import { createAction } from 'redux-promise-middleware-actions'; 49 | 50 | export const foo = createAction('FOO', (num) => num); 51 | 52 | dispatch(foo(5)); // { type: 'FOO', payload: 5 } 53 | ``` 54 | 55 | When handling the action in a reducer, you simply cast the action function to a string to return the type. This ensures type safety (no spelling errors) and you can use code navigation to find all uses of an action. 56 | 57 | ```js 58 | const fooType = foo.toString(); // 'FOO' 59 | ``` 60 | 61 | ### Asynchronous action 62 | 63 | When you create an asynchronous action you need to return a promise payload. If your action is called `FOO` the following events will be dispatched: 64 | 65 | 1. `FOO_PENDING` is dispatched immediately 66 | 2. `FOO_FULFILLED` is dispatched when the promise is resolved 67 | * ... or `FOO_REJECTED` is dispatched instead if the promise is rejected 68 | 69 | ```js 70 | import { createAsyncAction } from 'redux-promise-middleware-actions'; 71 | 72 | export const fetchData = createAsyncAction('FETCH_DATA', async () => { 73 | const res = await fetch(...); 74 | return res.json(); 75 | }); 76 | 77 | dispatch(fetchData()); // { type: 'FETCH_DATA_PENDING' } 78 | ``` 79 | 80 | An async action function has three properties to access the possible outcome actions: `pending`, `fulfilled` and `rejected`. You can dispatch them directly (in tests etc.): 81 | 82 | ```js 83 | dispatch(fetchData.pending()); // { type: 'FETCH_DATA_PENDING' } 84 | dispacth(fetchData.fulfilled(payload)); // { type: 'FETCH_DATA_FULFILLED', payload: ... } 85 | dispacth(fetchData.rejected(err)); // { type: 'FETCH_DATA_REJECTED', payload: err, error: true } 86 | ``` 87 | 88 | But normally you only need them when you are writing reducers: 89 | 90 | ```js 91 | case fetchData.pending.toString(): // 'FETCH_DATA_PENDING' 92 | case fetchData.fulfilled.toString(): // 'FETCH_DATA_FULFILLED' 93 | case fetchData.rejected.toString(): // 'FETCH_DATA_REJECTED' 94 | ``` 95 | 96 | Note that if you try and use the base function in a reducer, an error will be thrown to ensure you are not listening for an action that will never happen: 97 | 98 | ```js 99 | case fetchData.toString(): // throws an error 100 | ``` 101 | 102 | ### Reducer 103 | 104 | To create a type safe reducer, `createReducer` takes a list of handlers that accept one or more actions and returns the new state. You can use it with both synchronous and asynchronous action creators. 105 | 106 | #### `createReducer(defaultState, handlerMapsCreator)` 107 | 108 | ```js 109 | import { createAsyncAction, createReducer } from 'redux-promise-middleware-actions'; 110 | 111 | const fetchData = createAsyncAction('GET', () => fetch(...)); 112 | 113 | const defaultState = {}; 114 | 115 | const reducer = createReducer(defaultState, (handleAction) => [ 116 | handleAction(fetchData.pending, (state) => ({ ...state, pending: true })), 117 | handleAction(fetchData.fulfilled, (state, { payload }) => ({ ...state, pending: false, data: payload })), 118 | handleAction(fetchData.rejected, (state, { payload }) => ({ ...state, pending: false, error: payload })), 119 | ]); 120 | 121 | reducer(undefined, fetchData()); // { pending: true, data: ..., error: ... } 122 | ``` 123 | 124 | #### `asyncReducer(asyncActionCreator)` 125 | 126 | It can get tedious writing the same reducer for every single async action so we've included a simple reducer that does the same as the example above: 127 | 128 | ```js 129 | import { asyncReducer } from 'redux-promise-middleware-actions'; 130 | 131 | const fetchData = createAsyncAction('GET', () => fetch(...)); 132 | const fetchReducer = asyncReducer(fetchData); 133 | 134 | fetchReducer() // { data?: Payload, error?: Error, pending?: boolean } 135 | ``` 136 | 137 | You can also combine it with an existing reducer: 138 | 139 | ```js 140 | export default (state, action) => { 141 | const newState = fetchReducer(state, action); 142 | 143 | switch (action.type) { 144 | case 'SOME_OTHER_ACTION': 145 | return { ... }; 146 | default: 147 | return newState; 148 | } 149 | }; 150 | ``` 151 | 152 | ### Metadata 153 | 154 | You can add metadata to any action by supplying an additional metadata creator function. The metadata creator will receive the same arguments as the payload creator: 155 | 156 | #### `createAction(type, payloadCreator, metadataCreator)` 157 | 158 | ```js 159 | const foo = createAction( 160 | 'FOO', 161 | (num) => num, 162 | (num) => num + num 163 | ); 164 | 165 | dispatch(foo(5)); // { type: 'FOO', meta: 10, payload: 5 } 166 | ``` 167 | 168 | #### `createAsyncAction(type, payloadCreator, metadataCreator)` 169 | 170 | ```js 171 | const fetchData = createAsyncAction( 172 | 'FETCH_DATA', 173 | (n: number) => fetch(...), 174 | (n: number) => ({ n }) 175 | ); 176 | 177 | dispatch(fetchData(42)); 178 | // { type: 'FETCH_DATA_PENDING', meta: { n: 42 } } 179 | // { type: 'FETCH_DATA_FULFILLED', meta: { n: 42 }, payload: Promise<...> } 180 | // { type: 'FETCH_DATA_REJECTED', meta: { n: 42 }, payload: Error(...) } 181 | ``` 182 | 183 | ### Custome Type Delimiters 184 | 185 | You can specify a different type delimiter for your async actions: 186 | 187 | #### `createAsyncAction(type, payloadCreator, metadataCreator, options)` 188 | 189 | ```js 190 | const foo = createAsyncAction( 191 | 'FETCH_DATA', 192 | (n: number) => fetch(num), 193 | undefined, 194 | { promiseTypeDelimiter: '/' } 195 | ); 196 | 197 | dispatch(foo()); 198 | // { type: 'FETCH_DATA/PENDING' } 199 | // { type: 'FETCH_DATA/FULFILLED', payload: ... } 200 | // { type: 'FETCH_DATA/REJECTED', payload: ... } 201 | ``` 202 | 203 | ## Acknowledgements 204 | 205 | Thanks to [Deox](https://github.com/thebrodmann/deox/) for a lot of inspiration for the TypeScript types. 206 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-promise-middleware-actions", 3 | "version": "3.1.0", 4 | "description": "Redux action creator for making async actions with redux-promise-middleware", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0", 10 | "lint": "eslint . --ext .js,.ts,.tsx", 11 | "prepublishOnly": "tsc", 12 | "pretest": "npm run lint", 13 | "test": "nyc mocha", 14 | "version": "npm run changelog && git add CHANGELOG.md" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/omichelsen/redux-promise-middleware-actions.git" 19 | }, 20 | "keywords": [ 21 | "redux", 22 | "actions", 23 | "promise", 24 | "async" 25 | ], 26 | "author": "Ole Michelsen", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/omichelsen/redux-promise-middleware-actions/issues" 30 | }, 31 | "homepage": "https://github.com/omichelsen/redux-promise-middleware-actions#readme", 32 | "dependencies": { 33 | "tslib": "^2.4.0" 34 | }, 35 | "devDependencies": { 36 | "@types/mocha": "^9.1.1", 37 | "@types/node": "^17.0.41", 38 | "conventional-changelog-cli": "^2.2.2", 39 | "eslint-config-omichelsen": "^1.6.0", 40 | "mocha": "^10.0.0", 41 | "nyc": "^15.1.0", 42 | "ts-node": "^10.8.1", 43 | "typescript": "^4.7.3" 44 | }, 45 | "nyc": { 46 | "extension": [ 47 | ".ts" 48 | ], 49 | "include": [ 50 | "src/**/*.ts" 51 | ], 52 | "reporter": [ 53 | "lcov", 54 | "text-summary" 55 | ], 56 | "sourceMap": true, 57 | "instrument": true 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/actions.ts: -------------------------------------------------------------------------------- 1 | export const onPending = (type: any, delimiter = '_') => 2 | `${type}${delimiter}PENDING`; 3 | export const onFulfilled = (type: any, delimiter = '_') => 4 | `${type}${delimiter}FULFILLED`; 5 | export const onRejected = (type: any, delimiter = '_') => 6 | `${type}${delimiter}REJECTED`; 7 | 8 | export type Action< 9 | Type extends string, 10 | Payload = undefined, 11 | Meta = undefined 12 | > = Payload extends undefined 13 | ? Meta extends undefined 14 | ? { type: Type } 15 | : { type: Type; meta: Meta } 16 | : Payload extends Error 17 | ? Meta extends undefined 18 | ? { type: Type; payload: Payload; error: true } 19 | : { type: Type; payload: Payload; meta: Meta; error: true } 20 | : Meta extends undefined 21 | ? { type: Type; payload: Payload } 22 | : { type: Type; payload: Payload; meta: Meta }; 23 | 24 | export type AnyAction = Action; 25 | 26 | export type ActionCreator = { 27 | (...args: U): T; 28 | toString(): T['type']; 29 | }; 30 | 31 | export function createAction( 32 | type: Type 33 | ): ActionCreator>; 34 | 35 | export function createAction( 36 | type: Type, 37 | payloadCreator: (...args: U) => Payload 38 | ): ActionCreator, U>; 39 | 40 | export function createAction< 41 | Type extends string, 42 | Payload, 43 | Metadata, 44 | U extends any[] 45 | >( 46 | type: Type, 47 | payloadCreator: (...args: U) => Payload, 48 | metadataCreator?: (...args: U) => Metadata 49 | ): ActionCreator, U>; 50 | 51 | /** 52 | * Standard action creator factory. 53 | * @param type Action type. 54 | * @example 55 | * const addTodo = createAction('TODO_ADD', (name) => ({ name })); 56 | */ 57 | export function createAction< 58 | Type extends string, 59 | Payload, 60 | Metadata, 61 | U extends any[] 62 | >( 63 | type: Type, 64 | payloadCreator?: (...args: U) => Payload, 65 | metadataCreator?: (...args: U) => Metadata 66 | ) { 67 | return Object.assign( 68 | (...args: U) => ({ 69 | type, 70 | ...(payloadCreator && { payload: payloadCreator(...args) }), 71 | ...(metadataCreator && { meta: metadataCreator(...args) }), 72 | }), 73 | { toString: () => type } 74 | ); 75 | } 76 | 77 | export interface AsyncActionCreator 78 | extends ActionCreator, Metadata>> { 79 | pending: ActionCreator>; 80 | fulfilled: ActionCreator>; 81 | rejected: ActionCreator>; 82 | } 83 | 84 | /** 85 | * Asynchronous action creator factory. 86 | * @param type Action type. 87 | * @example 88 | * const getTodos = createAsyncAction('TODOS_GET', () => fetch('https://todos.com/todos')); 89 | */ 90 | export function createAsyncAction< 91 | Type extends string, 92 | Payload, 93 | Metadata, 94 | U extends any[] 95 | >( 96 | type: Type, 97 | payloadCreator: (...args: U) => Promise, 98 | metadataCreator?: (...args: U) => Metadata, 99 | { 100 | promiseTypeDelimiter: delimiter, 101 | }: { 102 | promiseTypeDelimiter?: string; 103 | } = {} 104 | ) { 105 | return Object.assign( 106 | createAction(type, payloadCreator, metadataCreator), 107 | { 108 | toString: () => { 109 | throw new Error( 110 | `Async action ${type} must be handled with pending, fulfilled or rejected` 111 | ); 112 | }, 113 | }, 114 | { 115 | pending: createAction(onPending(type, delimiter)), 116 | fulfilled: createAction( 117 | onFulfilled(type, delimiter), 118 | (payload: Payload) => payload 119 | ), 120 | rejected: createAction( 121 | onRejected(type, delimiter), 122 | (payload: Error) => payload 123 | ), 124 | } 125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { createAction, createAsyncAction } from './actions'; 2 | export { asyncReducer, createReducer } from './reducers'; 3 | -------------------------------------------------------------------------------- /src/reducers.ts: -------------------------------------------------------------------------------- 1 | import { ActionCreator, AnyAction, AsyncActionCreator } from './actions'; 2 | 3 | export function getType< 4 | TActionCreator extends { toString(): string }, 5 | Type extends string = TActionCreator extends { toString(): infer U } 6 | ? U 7 | : never 8 | >(actionCreator: TActionCreator) { 9 | return actionCreator.toString() as Type; 10 | } 11 | 12 | export type Handler< 13 | TPrevState, 14 | TAction, 15 | TNextState extends TPrevState = TPrevState 16 | > = (prevState: TPrevState, action: TAction) => TNextState; 17 | 18 | export type HandlerMap< 19 | TPrevState, 20 | TAction extends AnyAction, 21 | TNextState extends TPrevState = TPrevState 22 | > = { [type in TAction['type']]: Handler }; 23 | 24 | export type InferActionFromHandlerMap< 25 | THandlerMap extends HandlerMap 26 | > = THandlerMap extends HandlerMap ? T : never; 27 | 28 | export type InferNextStateFromHandlerMap< 29 | THandlerMap extends HandlerMap 30 | > = THandlerMap extends HandlerMap ? T : never; 31 | 32 | export type CreateHandlerMap = < 33 | TActionCreator extends ActionCreator, 34 | TNextState extends TPrevState, 35 | TAction extends AnyAction = TActionCreator extends (...args: any[]) => infer T 36 | ? T 37 | : never 38 | >( 39 | actionCreators: TActionCreator | TActionCreator[], 40 | handler: Handler 41 | ) => HandlerMap; 42 | 43 | export function createHandlerMap< 44 | TActionCreator extends ActionCreator, 45 | TPrevState, 46 | TNextState extends TPrevState, 47 | TAction extends AnyAction = TActionCreator extends (...args: any[]) => infer T 48 | ? T 49 | : never 50 | >( 51 | actionCreators: TActionCreator | TActionCreator[], 52 | handler: Handler 53 | ) { 54 | return (Array.isArray(actionCreators) ? actionCreators : [actionCreators]) 55 | .map(getType) 56 | .reduce>((acc, type) => { 57 | acc[type] = handler; 58 | return acc; 59 | }, {} as any); 60 | } 61 | 62 | export function createReducer< 63 | TState, 64 | THandlerMap extends HandlerMap 65 | >( 66 | defaultState: TState, 67 | handlerMapsCreator: (handle: CreateHandlerMap) => THandlerMap[] 68 | ) { 69 | const handlerMap = Object.assign({}, ...handlerMapsCreator(createHandlerMap)); 70 | 71 | return ( 72 | state = defaultState, 73 | action: InferActionFromHandlerMap 74 | ): InferNextStateFromHandlerMap => { 75 | const handler = handlerMap[action.type]; 76 | 77 | return handler ? handler(state, action) : state; 78 | }; 79 | } 80 | 81 | export interface State { 82 | error?: Error; 83 | data?: Payload; 84 | date?: number; 85 | pending?: boolean; 86 | } 87 | 88 | export const asyncReducer = ( 89 | fn: AsyncActionCreator 90 | ) => { 91 | const defaultState: State = {}; 92 | 93 | return createReducer(defaultState, (handleAction) => [ 94 | handleAction(fn.pending, (state) => ({ 95 | ...state, 96 | pending: true, 97 | })), 98 | handleAction(fn.fulfilled, (state, { payload }: any) => ({ 99 | ...state, 100 | pending: false, 101 | error: undefined, 102 | data: payload, 103 | date: Date.now(), 104 | })), 105 | handleAction(fn.rejected, (state, { payload }) => ({ 106 | ...state, 107 | pending: false, 108 | error: payload, 109 | })), 110 | ]); 111 | }; 112 | -------------------------------------------------------------------------------- /test/actions.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { 3 | createAction, 4 | createAsyncAction, 5 | onFulfilled, 6 | onPending, 7 | onRejected, 8 | } from '../src/actions'; 9 | 10 | describe('actions', () => { 11 | const TYPE = 'TEST_ACTION'; 12 | 13 | describe('createAction', () => { 14 | it('should create an action object with type and no payload', () => { 15 | const action = createAction(TYPE); 16 | assert.deepEqual(action(), { type: TYPE }); 17 | }); 18 | 19 | it('should output action name on toString()', () => { 20 | const action = createAction(TYPE); 21 | assert.strictEqual(action.toString(), TYPE); 22 | }); 23 | 24 | it('should add the input to the payload', () => { 25 | const action = createAction(TYPE, (input: number) => input); 26 | assert.strictEqual(action(1234).payload, 1234); 27 | }); 28 | 29 | it('should execute the action creator and add it to the payload', () => { 30 | const action = createAction(TYPE, (a: number, b: number) => a + b); 31 | assert.strictEqual(action(40, 2).payload, 42); 32 | }); 33 | 34 | describe('metadataCreator', () => { 35 | it('should not have metadata', () => { 36 | const action = createAction(TYPE, (n: number) => ({ n })); 37 | assert.strictEqual('meta' in action(42), false); 38 | }); 39 | 40 | it('should forward same payload and metadata', () => { 41 | const action = createAction( 42 | TYPE, 43 | (n: number) => ({ n }), 44 | (n: number) => ({ n }) 45 | ); 46 | assert.deepEqual(action(42), { 47 | type: TYPE, 48 | payload: { n: 42 }, 49 | meta: { n: 42 }, 50 | }); 51 | }); 52 | 53 | it('should have different payload and metadata', () => { 54 | const action = createAction( 55 | TYPE, 56 | (n: number) => ({ n }), 57 | () => ({ asdf: 1234 }) 58 | ); 59 | assert.deepEqual(action(42), { 60 | type: TYPE, 61 | payload: { n: 42 }, 62 | meta: { asdf: 1234 }, 63 | }); 64 | }); 65 | 66 | it('should have only metadata', () => { 67 | const action = createAction( 68 | TYPE, 69 | () => undefined, 70 | () => ({ asdf: 1234 }) 71 | ); 72 | assert.deepEqual(action(), { 73 | type: TYPE, 74 | payload: undefined, 75 | meta: { asdf: 1234 }, 76 | }); 77 | }); 78 | }); 79 | }); 80 | 81 | describe('createAsyncAction', () => { 82 | const action = createAsyncAction(TYPE, (s: string) => Promise.resolve(s)); 83 | 84 | it('should create an action with correct type', () => { 85 | assert.strictEqual(action('x').type, TYPE); 86 | }); 87 | 88 | it('should create an action with Promise payload', async () => { 89 | assert(action('x').payload instanceof Promise); 90 | }); 91 | 92 | it('should create an action with correct payload', async () => { 93 | assert.strictEqual(await action('x').payload, 'x'); 94 | }); 95 | 96 | it('should throw on toString()', () => { 97 | assert.throws( 98 | () => action.toString(), 99 | new RegExp( 100 | `Async action ${TYPE} must be handled with pending, fulfilled or rejected` 101 | ) 102 | ); 103 | }); 104 | 105 | it('should create action with 0 arguments', async () => { 106 | const action0 = createAsyncAction(TYPE, () => Promise.resolve(42)); 107 | assert.strictEqual(await action0().payload, 42); 108 | }); 109 | 110 | it('should create action with 1 arguments', async () => { 111 | const action1 = createAsyncAction(TYPE, (n: number) => 112 | Promise.resolve(n) 113 | ); 114 | assert.strictEqual(await action1(42).payload, 42); 115 | }); 116 | 117 | it('should create action with 2 arguments', async () => { 118 | const action1 = createAsyncAction(TYPE, (n: number, s: string) => 119 | Promise.resolve({ n, s }) 120 | ); 121 | assert.deepEqual(await action1(42, 'hello').payload, { 122 | n: 42, 123 | s: 'hello', 124 | }); 125 | }); 126 | 127 | it('should create action with 3 arguments', async () => { 128 | const action1 = createAsyncAction( 129 | TYPE, 130 | (n: number, s: string, b: boolean) => Promise.resolve({ n, s, b }) 131 | ); 132 | assert.deepEqual(await action1(42, 'hello', true).payload, { 133 | n: 42, 134 | s: 'hello', 135 | b: true, 136 | }); 137 | }); 138 | 139 | describe('pending', () => { 140 | it('should have a pending action prop', () => { 141 | assert('pending' in action); 142 | }); 143 | 144 | it('should output pending action name on toString()', () => { 145 | assert.strictEqual(action.pending.toString(), onPending(TYPE)); 146 | }); 147 | }); 148 | 149 | describe('fulfilled', () => { 150 | it('should have a fulfilled action prop', () => { 151 | assert('fulfilled' in action); 152 | }); 153 | 154 | it('should output fulfilled action name on toString()', () => { 155 | assert.strictEqual(action.fulfilled.toString(), onFulfilled(TYPE)); 156 | }); 157 | 158 | it('should create a fulfilled action with payload', () => { 159 | assert.deepEqual(action.fulfilled('payload'), { 160 | type: onFulfilled(TYPE), 161 | payload: 'payload', 162 | }); 163 | }); 164 | }); 165 | 166 | describe('rejected', () => { 167 | it('should have a rejected action prop', () => { 168 | assert('rejected' in action); 169 | }); 170 | 171 | it('should output rejected action name on toString()', () => { 172 | assert.strictEqual(action.rejected.toString(), onRejected(TYPE)); 173 | }); 174 | 175 | it('should create a rejected action with payload', () => { 176 | const err = new Error('error'); 177 | assert.deepEqual(action.rejected(err), { 178 | type: onRejected(TYPE), 179 | payload: err, 180 | }); 181 | }); 182 | }); 183 | 184 | describe('promiseTypeDelimiter', () => { 185 | const actionDelimiter = createAsyncAction( 186 | TYPE, 187 | (s: string) => Promise.resolve(s), 188 | undefined, 189 | { promiseTypeDelimiter: '#' } 190 | ); 191 | 192 | it('should use custom delimiter in pending action type', () => { 193 | assert.strictEqual( 194 | actionDelimiter.pending.toString(), 195 | onPending(TYPE, '#') 196 | ); 197 | }); 198 | 199 | it('should use custom delimiter in fulfilled action type', () => { 200 | assert.strictEqual( 201 | actionDelimiter.fulfilled.toString(), 202 | onFulfilled(TYPE, '#') 203 | ); 204 | }); 205 | 206 | it('should use custom delimiter in rejected action type', () => { 207 | assert.strictEqual( 208 | actionDelimiter.rejected.toString(), 209 | onRejected(TYPE, '#') 210 | ); 211 | }); 212 | }); 213 | }); 214 | }); 215 | -------------------------------------------------------------------------------- /test/reducers.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { createAction, createAsyncAction } from '../src/actions'; 3 | import { asyncReducer, createReducer } from '../src/reducers'; 4 | 5 | describe('reducers', () => { 6 | const TYPE = 'TEST_ACTION'; 7 | 8 | describe('createReducer', () => { 9 | it('should handle a number reducer', () => { 10 | const increment = createAction('INCREMENT'); 11 | const decrement = createAction('DECREMENT'); 12 | const reset = createAction('RESET', (count: number) => count); 13 | 14 | const reducer = createReducer(0, (handleAction) => [ 15 | handleAction(increment, (state) => state + 1), 16 | handleAction(decrement, (state) => state - 1), 17 | handleAction(reset, (_, { payload }) => payload), 18 | ]); 19 | 20 | assert.strictEqual( 21 | reducer(undefined, increment()), 22 | 1, 23 | 'increment state by one' 24 | ); 25 | assert.strictEqual( 26 | reducer(undefined, decrement()), 27 | -1, 28 | 'decrement state by one' 29 | ); 30 | assert.strictEqual(reducer(3, reset(0)), 0, 'reset state to zero'); 31 | }); 32 | 33 | it('should handle multiple actions', () => { 34 | const a = createAction('A'); 35 | const b = createAction('B'); 36 | 37 | const reducer = createReducer(0, (handleAction) => [ 38 | handleAction([a, b], (state) => state + 1), 39 | ]); 40 | 41 | assert.strictEqual(reducer(undefined, a()), 1); 42 | assert.strictEqual(reducer(undefined, b()), 1); 43 | }); 44 | 45 | it('should handle unknown actions', () => { 46 | const known = createAction('KNOWN'); 47 | const unknown = createAction('UNKNOWN'); 48 | 49 | const reducer = createReducer(0, (handleAction) => [ 50 | handleAction(known, (state) => state + 1), 51 | ]); 52 | 53 | assert.strictEqual(reducer(0, unknown() as any), 0); 54 | }); 55 | 56 | describe('createAsyncAction', () => { 57 | const get = createAsyncAction('GET', (value: string) => 58 | Promise.resolve(value) 59 | ); 60 | 61 | const defaultState = { 62 | data: '', 63 | error: undefined as unknown as Error, 64 | pending: false, 65 | }; 66 | 67 | const reducer = createReducer(defaultState, (handleAction) => [ 68 | handleAction(get.pending, (state) => ({ ...state, pending: true })), 69 | handleAction(get.fulfilled, (state, { payload }) => ({ 70 | ...state, 71 | pending: false, 72 | data: payload, 73 | })), 74 | handleAction(get.rejected, (state) => ({ 75 | ...state, 76 | pending: false, 77 | error: new Error('fail'), 78 | })), 79 | ]); 80 | 81 | it('should set pending to true', () => { 82 | assert.deepStrictEqual(reducer(undefined, get.pending()), { 83 | ...defaultState, 84 | pending: true, 85 | }); 86 | }); 87 | 88 | it('should set pending to false and sets data', () => { 89 | assert.deepStrictEqual(reducer(undefined, get.fulfilled('data')), { 90 | ...defaultState, 91 | pending: false, 92 | data: 'data', 93 | }); 94 | }); 95 | 96 | it('should set pending to false and sets error', () => { 97 | assert.deepStrictEqual( 98 | reducer(undefined, get.rejected(new Error('fail'))), 99 | { 100 | ...defaultState, 101 | pending: false, 102 | error: new Error('fail'), 103 | } 104 | ); 105 | }); 106 | }); 107 | }); 108 | 109 | describe('asyncReducer', () => { 110 | const action = createAsyncAction(TYPE, () => Promise.resolve(42)); 111 | const reducer = asyncReducer(action); 112 | 113 | it('should return default state if unknown action', () => { 114 | const state = reducer({}, { type: 'UNKNOWN' }); 115 | assert.deepStrictEqual(state, {}); 116 | }); 117 | 118 | it('should set pending to true on pending', () => { 119 | const state = reducer(undefined, action.pending()); 120 | assert.deepStrictEqual(state, { pending: true }); 121 | }); 122 | 123 | it('should set pending to false on fulfilled', () => { 124 | const state = reducer(undefined, action.fulfilled(42)); 125 | assert.strictEqual(state.pending, false); 126 | }); 127 | 128 | it('should set data on fulfilled', () => { 129 | const state = reducer(undefined, action.fulfilled(42)); 130 | assert.strictEqual(state.data, 42); 131 | }); 132 | 133 | it('should set date on fulfilled', () => { 134 | const state = reducer(undefined, action.fulfilled(42)); 135 | assert.strictEqual(state.date, Date.now()); 136 | }); 137 | 138 | it('should set error to undefined on fulfilled', () => { 139 | const state = reducer( 140 | { error: new Error('oops!') }, 141 | action.fulfilled(42) 142 | ); 143 | assert.strictEqual(state.error, undefined); 144 | }); 145 | 146 | it('should set pending to false on rejected', () => { 147 | const state = reducer(undefined, action.rejected(new Error('err'))); 148 | assert.strictEqual(state.pending, false); 149 | }); 150 | 151 | it('should set error on rejected', () => { 152 | const state = reducer(undefined, action.rejected(new Error('oops!'))); 153 | assert.deepStrictEqual(state.error, new Error('oops!')); 154 | }); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "esModuleInterop": true, 5 | "importHelpers": true, 6 | "lib": [ 7 | "es7" 8 | ], 9 | "module": "commonjs", 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "outDir": "lib", 13 | "sourceMap": true, 14 | "strict": true, 15 | "target": "es5" 16 | }, 17 | "include": [ 18 | "src/**/*.ts" 19 | ] 20 | } --------------------------------------------------------------------------------