├── .browserslistrc ├── .editorconfig ├── .eslintrc.json ├── .gitattributes ├── .gitignore ├── .huskyrc.json ├── .lintstagedrc.json ├── .npmignore ├── .prettierrc.json ├── .travis.yml ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── src ├── index.ts ├── types │ ├── async-function-state.ts │ ├── async-function.ts │ ├── reduced-stateful-async-function.ts │ ├── state.ts │ └── stateful-async-function.ts ├── use-async-function.test.ts ├── use-async-function.ts └── utils │ ├── async-function-reducer.test.ts │ └── async-function-reducer.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | IE 11 2 | last 3 Chrome versions 3 | last 3 Edge versions 4 | last 3 Firefox versions 5 | last 2 Safari versions 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true, 4 | "node": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/eslint-recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:prettier/recommended", 11 | "plugin:react/recommended", 12 | "prettier/@typescript-eslint", 13 | "prettier/react" 14 | ], 15 | "parser": "@typescript-eslint/parser", 16 | "parserOptions": { 17 | "ecmaFeatures": { 18 | "experimentalObjectRestSpread": true, 19 | "jsx": false 20 | }, 21 | "project": "./tsconfig.json", 22 | "tsconfigRootDir": "./", 23 | "useJSXTextNode": true, 24 | "warnOnUnsupportedTypeScriptVersion": false 25 | }, 26 | "plugins": ["@typescript-eslint", "prettier", "react", "react-hooks"], 27 | "rules": { 28 | "prettier/prettier": "error", 29 | "react-hooks/exhaustive-deps": "error", 30 | "react-hooks/rules-of-hooks": "error" 31 | }, 32 | "settings": { 33 | "react": { 34 | "version": "detect" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.git 2 | /.vscode 3 | /build 4 | /coverage 5 | /dist 6 | /jest 7 | /node_modules 8 | /.env 9 | /yarn-error.log 10 | -------------------------------------------------------------------------------- /.huskyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": ["**/*.md", "**/*.snap"], 3 | "linters": { 4 | "src/**/*.{js,json,jsx,ts,tsx}": [ 5 | "eslint --fix", 6 | "prettier --write", 7 | "git add" 8 | ] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /.git 2 | /.vscode 3 | /build 4 | /coverage 5 | /jest 6 | /node_modules 7 | /src 8 | /tests 9 | /.editorconfig 10 | /.env 11 | /.gitattributes 12 | /.gitignore 13 | /.huskyrc.json 14 | /.lintstagedrc.json 15 | /.npmignore 16 | /.prettierrc.json 17 | /jest.config.js 18 | /package-lock.json 19 | /tsconfig.json 20 | /webpack.config.js 21 | /yarn.lock 22 | /yarn-error.log 23 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "endOfLine": "lf", 5 | "htmlWhitespaceSensitivity": "css", 6 | "jsxBracketSameLine": false, 7 | "jsxSingleQuote": false, 8 | "printWidth": 80, 9 | "quoteProps": "as-needed", 10 | "semi": true, 11 | "singleQuote": true, 12 | "tabWidth": 2, 13 | "trailingComma": "all", 14 | "useTabs": false 15 | } 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 10.15.3 3 | branches: 4 | only: master 5 | cache: yarn 6 | install: 7 | - yarn 8 | script: 9 | - yarn build 10 | - yarn test 11 | deploy: 12 | api_key: 13 | secure: JB7H3o4kMPytKPN3Lv2qYAfA5JeAzajVkN5ercusSawnZ0yQMERqUFSBGE7DWpxAg0M5CLu3zIcDCMX4TsevrjZIGA/mRuvM7dhHGKyS1N3UCrXjX+A0QT1mC6hSIBAseFEEaagGN0zR4Cg74e3Gqh37+D27VTjioMADw/pjOG2IQi/QmPYjKwAqYMTfGSuvdzLEOMDt+YzSw5XR5pzQk4CpvGmTM1L63cUp4Pkixc1CkrP/NXpQqHJG07nBzMnjlurw5dGhEux4hzAUMwCGOVnqYxslMN6ejRuJ0UnL/fTOGyGY++zERfsq3zAARKQBbALXWzx46sqO/4k5Z6HVhnUZuLTNPvL7+BvIQ0vpF3RyK9dESIbM0E0vTzdn3WGpFv9Nj1lSmgcsBqddMHjYjPK+mICGDdr+tsRUndjI1YYOxog4zJjTpBdj6zQBiVaacFTzF4RX1afplUdt7GbBc6OLLIjRKKACEzAdq1EH4fGyOBedWvxb/2rcan6LbwBoB/6hJNcz7PhTaDFRglRst+uL1/8+N8MnnKzZ0nThoZJw2d5jKdc9UmUO1b2ah+IhllWBiNI89mgeYzHm7jLRFUgF97Ddx/flFWnLsDI1DWnPIGZ6k02h4o5bLAkKyS71QnMU3bZdysOLMxtlFUzvi8qiFcZqoKDtyAVAI+7Dfpw= 14 | email: npmjs@charlesstover.com 15 | on: 16 | branch: master 17 | provider: npm 18 | skip_cleanup: true 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Charles Stover 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # useAsyncFunction [![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=useAsyncFunction%20integrates%20your%20asynchronous%20function%20state%20with%20a%20React%20component's%20local%20state.&url=https://github.com/CharlesStover/use-async-function&via=CharlesStover&hashtags=react,reactjs,javascript,typescript,webdev,webdevelopment) [![version](https://img.shields.io/npm/v/use-async-function.svg)](https://www.npmjs.com/package/use-async-function) [![minzipped size](https://img.shields.io/bundlephobia/minzip/use-async-function.svg)](https://www.npmjs.com/package/use-async-function) [![downloads](https://img.shields.io/npm/dt/use-async-function.svg)](https://www.npmjs.com/package/use-async-function) [![build](https://api.travis-ci.com/CharlesStover/use-async-function.svg)](https://travis-ci.com/CharlesStover/use-async-function/) 2 | 3 | `useAsyncFunction` is a React hook that integrates an asynchronous function's 4 | state with a React function component's state. This automates the process of 5 | re-rendering the React function component as the asynchronous function's state 6 | changes between pending, resolved, and rejected. 7 | 8 | ## Install 9 | 10 | - `npm i use-async-function` or 11 | - `yarn add use-async-function` 12 | 13 | ## Use 14 | 15 | ```javascript 16 | import useAsyncFunction, { State } from 'use-async-function'; 17 | 18 | const myAsyncFunction = async (): Promise => { 19 | await one(); 20 | await two(); 21 | return 'three'; 22 | }; 23 | 24 | function MyComponent() { 25 | const dispatch = useAsyncFunction(myAsyncFunction); 26 | 27 | if (!dispatch.state) { 28 | dispatch(); 29 | } 30 | 31 | if (dispatch.state === State.Fulfilled) { 32 | return
The response was {dispatch.value}.
; 33 | } 34 | 35 | if (dispatch.state === State.Rejected) { 36 | return
An error occurred: {dispatch.error}
; 37 | } 38 | 39 | // Pending and uninitiated. 40 | return ; 41 | } 42 | ``` 43 | 44 | ## Parameters 45 | 46 | ### useAsyncFunction(Function) 47 | 48 | The first parameter of the `useAsyncFunction` hook is the asynchronous function 49 | itself. 50 | 51 | ### useAsyncFunction(Function, Options) 52 | 53 | If you are not using an [identity reducer](#identity-reducer), the second 54 | parameter of the `useAsyncFunction` hook is the [options](#options) object. 55 | 56 | ### useAsyncFunction(Function, Reducer) 57 | 58 | If you are using an [identity reducer](#identity-reducer), the second parameter 59 | of the `useAsyncFunction` hook is the identity reducer. 60 | 61 | ### useAsyncFunction(Function, Reducer, Options) 62 | 63 | If you are using both an [identity reducer](#identity-reducer) and an 64 | [options](#options) object, the identity reducer is the second parameter of the 65 | `useAsyncFunction` hook, and the options object is the third parameter of the 66 | `useAsyncFunction` hook. 67 | 68 | ## Identity Reducer 69 | 70 | An identity reducer allows you to track multiple calls to the same asynchronous 71 | function. This is particularly useful when tracking API calls, where each call's 72 | fulfillment, pending, and rejection states are unique. 73 | 74 | An identity reducer takes the call's arguments as its arguments and returns a 75 | string unique to that call. 76 | 77 | ```javascript 78 | // Asynchronously fetch a user's data by their username. 79 | function fetchUser({ username }) { 80 | return fetch(`/users/${username}`); 81 | } 82 | 83 | // Uniquely identify an asynchronous function call by the username. 84 | function idByUsername({ username }) { 85 | return username; 86 | } 87 | 88 | function MyComponent() { 89 | const dispatch = useAsyncFunction(fetchUser, idByUsername); 90 | 91 | // We can determine if this call has been made by the presence of a property 92 | // with the call's ID. 93 | if (!dispatch.admin) { 94 | dispatch({ username: 'admin' }); 95 | return null; 96 | } 97 | if (!dispatch.bob) { 98 | dispatch({ username: 'bob' }); 99 | return null; 100 | } 101 | 102 | // The state of the call is stored on the property with the call's ID. 103 | if (dispatch.admin.state === State.Fulfilled) { 104 | return
Admin loaded with {dispatch.admin.value}.
; 105 | } 106 | } 107 | ``` 108 | 109 | ## Options 110 | 111 | ### throwError 112 | 113 | **Default:** `false` 114 | 115 | **Type:** `boolean` 116 | 117 | If `true`, the asynchronous function will throw any encountered errors, 118 | requiring a `.catch` block. 119 | 120 | If `false`, the asynchronous function will swallow any errors. The component 121 | will rerender, and the `error` property of the async function will be set to the 122 | caught error. 123 | 124 | ## Sponsor 💗 125 | 126 | If you are a fan of this project, you may 127 | [become a sponsor](https://github.com/sponsors/CharlesStover) 128 | via GitHub's Sponsors Program. 129 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-async-function", 3 | "version": "1.0.3", 4 | "author": "Charles Stover ", 5 | "description": "A React hook for integrating asynchronous function state into React function component state.", 6 | "homepage": "https://www.npmjs.com/package/use-async-function", 7 | "main": "./dist/index.js", 8 | "repository": "https://github.com/CharlesStover/use-async-function", 9 | "types": "./dist/index.d.ts", 10 | "bugs": { 11 | "email": "use-async-function@charlesstover.com", 12 | "url": "https://github.com/CharlesStover/use-async-function/issues" 13 | }, 14 | "directories": { 15 | "lib": "src" 16 | }, 17 | "files": [ 18 | "dist", 19 | "package.json", 20 | "README.md" 21 | ], 22 | "scripts": { 23 | "build": "npm run prepublishOnly", 24 | "clean": "rm -rf build dist node_modules", 25 | "prepublishOnly": "webpack", 26 | "test": "jest", 27 | "test-watch": "jest --watch" 28 | }, 29 | "dependencies": {}, 30 | "devDependencies": { 31 | "@testing-library/react-hooks": "^2.0.0", 32 | "@types/jest": "^23.0.0", 33 | "@types/node": "^11.0.0", 34 | "@types/react": "^16.3.0", 35 | "@types/react-dom": "^16.3.0", 36 | "@typescript-eslint/eslint-plugin": "^1.0.0", 37 | "@typescript-eslint/parser": "^1.0.0", 38 | "configure-webpack": "^1.0.2", 39 | "eslint": "^5.0.0", 40 | "eslint-config-prettier": "^4.0.0", 41 | "eslint-plugin-prettier": "^3.0.0", 42 | "eslint-plugin-react": "^7.0.0", 43 | "eslint-plugin-react-hooks": "^1.0.0", 44 | "husky": "^2.0.0", 45 | "jest": "^24.0.0", 46 | "lint-staged": "^8.0.0", 47 | "prettier": "^1.0.0", 48 | "react": "^16.8.0", 49 | "react-dom": "^16.8.0", 50 | "react-test-renderer": "^16.8.0", 51 | "ts-jest": "^24.0.0", 52 | "webpack": "^4.0.0", 53 | "webpack-cli": "^3.0.0" 54 | }, 55 | "peerDependencies": { 56 | "react": "^16.8.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as State } from './types/state'; 2 | export { default } from './use-async-function'; 3 | -------------------------------------------------------------------------------- /src/types/async-function-state.ts: -------------------------------------------------------------------------------- 1 | import State from './state'; 2 | 3 | type AsyncFunctionState = 4 | | Fulfilled 5 | | Pending 6 | | Rejected 7 | | Undefined; 8 | export default AsyncFunctionState; 9 | 10 | interface Fulfilled { 11 | error: undefined; 12 | state: State.Fulfilled; 13 | value: T; 14 | } 15 | 16 | interface Pending { 17 | error: undefined; 18 | state: State.Pending; 19 | value: undefined; 20 | } 21 | 22 | interface Rejected { 23 | error: E; 24 | state: State.Rejected; 25 | value: undefined; 26 | } 27 | 28 | interface Undefined { 29 | error: undefined; 30 | state: undefined; 31 | value: undefined; 32 | } 33 | -------------------------------------------------------------------------------- /src/types/async-function.ts: -------------------------------------------------------------------------------- 1 | /* eslint @typescript-eslint/no-explicit-any: 0 */ 2 | 3 | export default interface AsyncFunction { 4 | (...args: A): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/types/reduced-stateful-async-function.ts: -------------------------------------------------------------------------------- 1 | /* eslint @typescript-eslint/no-explicit-any: 0 */ 2 | import AsyncFunction from './async-function'; 3 | import AsyncFunctionState from './async-function-state'; 4 | 5 | export default interface ReducedStatefulAsyncFunction< 6 | A extends any[], 7 | T, 8 | E = Error 9 | > extends AsyncFunction { 10 | [id: string]: AsyncFunctionState | void; 11 | } 12 | -------------------------------------------------------------------------------- /src/types/state.ts: -------------------------------------------------------------------------------- 1 | enum State { 2 | Fulfilled = 'FULFILLED', 3 | Pending = 'PENDING', 4 | Rejected = 'REJECTED', 5 | } 6 | 7 | export default State; 8 | -------------------------------------------------------------------------------- /src/types/stateful-async-function.ts: -------------------------------------------------------------------------------- 1 | /* eslint @typescript-eslint/no-explicit-any: 0 */ 2 | import AsyncFunction from './async-function'; 3 | import AsyncFunctionState from './async-function-state'; 4 | 5 | type StatefulAsyncFunction< 6 | A extends any[], 7 | T extends any, 8 | E = Error 9 | > = AsyncFunction & AsyncFunctionState; 10 | export default StatefulAsyncFunction; 11 | -------------------------------------------------------------------------------- /src/use-async-function.test.ts: -------------------------------------------------------------------------------- 1 | import { act, HookResult, renderHook } from '@testing-library/react-hooks'; 2 | import ReducedStatefulAsyncFunction from './types/reduced-stateful-async-function'; 3 | import State from './types/state'; 4 | import StatefulAsyncFunction from './types/stateful-async-function'; 5 | import useAsyncFunction from './use-async-function'; 6 | import AsyncFunctionState from './types/async-function-state'; 7 | 8 | const TEST_ASYNC_FUNCTION = (str: string): Promise => 9 | Promise.resolve(str); 10 | 11 | const TEST_STR = 'test str'; 12 | 13 | describe('useAsyncFunction', (): void => { 14 | describe('with reducer', (): void => { 15 | const TEST_REDUCER = (str: string): string => str; 16 | 17 | let result: HookResult< 18 | ReducedStatefulAsyncFunction<[string], string | undefined, Error> 19 | >; 20 | 21 | describe('fulfilled', (): void => { 22 | beforeEach((): void => { 23 | result = renderHook( 24 | (): ReducedStatefulAsyncFunction< 25 | [string], 26 | string | undefined, 27 | Error 28 | > => useAsyncFunction(TEST_ASYNC_FUNCTION, TEST_REDUCER), 29 | ).result; 30 | }); 31 | 32 | it('should generate a function with no state', (): void => { 33 | expect(result.current).toBeInstanceOf(Function); 34 | expect(result.current[TEST_STR]).toBeUndefined(); 35 | }); 36 | 37 | it('should return a Promise', async (): Promise => { 38 | let promise: Promise; 39 | act((): void => { 40 | promise = result.current(TEST_STR); 41 | expect(promise).toBeInstanceOf(Promise); 42 | }); 43 | 44 | let value: string | undefined; 45 | await act( 46 | async (): Promise => { 47 | value = await promise; 48 | }, 49 | ); 50 | expect(value).toBe(TEST_STR); 51 | }); 52 | 53 | it('should update the state', async (): Promise => { 54 | let promise: Promise; 55 | act((): void => { 56 | promise = result.current(TEST_STR); 57 | }); 58 | 59 | let current: AsyncFunctionState | void = 60 | result.current[TEST_STR]; 61 | if (!current) { 62 | throw new Error('State was not created.'); 63 | } 64 | expect(current.error).toBeUndefined(); 65 | expect(current.state).toBe(State.Pending); 66 | expect(current.value).toBeUndefined(); 67 | 68 | await act( 69 | async (): Promise => { 70 | await promise; 71 | }, 72 | ); 73 | 74 | current = result.current[TEST_STR]; 75 | if (!current) { 76 | throw new Error('State was lost.'); 77 | } 78 | expect(current.error).toBeUndefined(); 79 | expect(current.state).toBe(State.Fulfilled); 80 | expect(current.value).toBe(TEST_STR); 81 | }); 82 | }); 83 | }); 84 | 85 | describe('without reducer', (): void => { 86 | let result: HookResult< 87 | StatefulAsyncFunction<[string], string | undefined, Error> 88 | >; 89 | 90 | describe('fulfilled', (): void => { 91 | beforeEach((): void => { 92 | result = renderHook( 93 | (): StatefulAsyncFunction<[string], string | undefined, Error> => 94 | useAsyncFunction(TEST_ASYNC_FUNCTION), 95 | ).result; 96 | }); 97 | 98 | it('should generate a function with no state', (): void => { 99 | expect(result.current).toBeInstanceOf(Function); 100 | expect(result.current.error).toBeUndefined(); 101 | expect(result.current.state).toBeUndefined(); 102 | expect(result.current.value).toBeUndefined(); 103 | }); 104 | 105 | it('should return a Promise', async (): Promise => { 106 | let promise: Promise; 107 | act((): void => { 108 | promise = result.current(TEST_STR); 109 | expect(promise).toBeInstanceOf(Promise); 110 | }); 111 | 112 | let value: string | undefined; 113 | await act( 114 | async (): Promise => { 115 | value = await promise; 116 | }, 117 | ); 118 | expect(value).toBe(TEST_STR); 119 | }); 120 | 121 | it('should update the state', async (): Promise => { 122 | let promise: Promise; 123 | act((): void => { 124 | promise = result.current(TEST_STR); 125 | }); 126 | expect(result.current.error).toBeUndefined(); 127 | expect(result.current.state).toBe(State.Pending); 128 | expect(result.current.value).toBeUndefined(); 129 | 130 | await act( 131 | async (): Promise => { 132 | await promise; 133 | }, 134 | ); 135 | expect(result.current.error).toBeUndefined(); 136 | expect(result.current.state).toBe(State.Fulfilled); 137 | expect(result.current.value).toBe(TEST_STR); 138 | }); 139 | }); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /src/use-async-function.ts: -------------------------------------------------------------------------------- 1 | /* eslint @typescript-eslint/no-explicit-any: 0 */ 2 | import { 3 | Reducer, 4 | MutableRefObject, 5 | useEffect, 6 | useMemo, 7 | useReducer, 8 | useRef, 9 | } from 'react'; 10 | import AsyncFunction from './types/async-function'; 11 | import AsyncFunctionState from './types/async-function-state'; 12 | import ReducedStatefulAsyncFunction from './types/reduced-stateful-async-function'; 13 | import StatefulAsyncFunction from './types/stateful-async-function'; 14 | import State from './types/state'; 15 | import asyncFunctionReducer, { Action } from './utils/async-function-reducer'; 16 | 17 | type AsyncFunctionIdReducer = (...args: A) => string; 18 | 19 | type Options = OptionsThrowErrorFalse | OptionsThrowErrorTrue; 20 | 21 | interface OptionsThrowErrorFalse { 22 | throwError?: false; 23 | } 24 | 25 | interface OptionsThrowErrorTrue { 26 | throwError: true; 27 | } 28 | 29 | type VoidFunction = () => void; 30 | 31 | const DEFAULT_OPTIONS: Options = Object.create(null); 32 | const DEFAULT_REDUCER: AsyncFunctionIdReducer = (): '_' => '_'; 33 | 34 | export default function useAsyncFunction< 35 | A extends any[], 36 | T extends any, 37 | E = Error 38 | >( 39 | asyncFunction: AsyncFunction, 40 | ): StatefulAsyncFunction; 41 | 42 | export default function useAsyncFunction< 43 | A extends any[], 44 | T extends any, 45 | E = Error 46 | >( 47 | asyncFunction: AsyncFunction, 48 | reducer: AsyncFunctionIdReducer, 49 | ): ReducedStatefulAsyncFunction; 50 | 51 | export default function useAsyncFunction< 52 | A extends any[], 53 | T extends any, 54 | E = Error 55 | >( 56 | asyncFunction: AsyncFunction, 57 | reducer: AsyncFunctionIdReducer, 58 | options: OptionsThrowErrorFalse, 59 | ): ReducedStatefulAsyncFunction; 60 | 61 | export default function useAsyncFunction< 62 | A extends any[], 63 | T extends any, 64 | E = Error 65 | >( 66 | asyncFunction: AsyncFunction, 67 | reducer: AsyncFunctionIdReducer, 68 | options: OptionsThrowErrorTrue, 69 | ): ReducedStatefulAsyncFunction; 70 | 71 | export default function useAsyncFunction< 72 | A extends any[], 73 | T extends any, 74 | E = Error 75 | >( 76 | asyncFunction: AsyncFunction, 77 | options: OptionsThrowErrorFalse, 78 | ): StatefulAsyncFunction; 79 | 80 | export default function useAsyncFunction< 81 | A extends any[], 82 | T extends any, 83 | E = Error 84 | >( 85 | asyncFunction: AsyncFunction, 86 | options: OptionsThrowErrorTrue, 87 | ): StatefulAsyncFunction; 88 | 89 | export default function useAsyncFunction< 90 | A extends any[], 91 | T extends any, 92 | E = Error 93 | >( 94 | asyncFunction: AsyncFunction, 95 | _reducer: Options | AsyncFunctionIdReducer = DEFAULT_REDUCER, 96 | _options: Options = DEFAULT_OPTIONS, 97 | ): 98 | | ReducedStatefulAsyncFunction 99 | | StatefulAsyncFunction { 100 | // Sanitize user input by converting (func, options) to 101 | // (func, reducer, options) 102 | const reducer: AsyncFunctionIdReducer = 103 | typeof _reducer === 'function' ? _reducer : DEFAULT_REDUCER; 104 | const options: Options = typeof _reducer === 'object' ? _reducer : _options; 105 | 106 | const [state, setState] = useReducer< 107 | Reducer>, Action> 108 | >(asyncFunctionReducer, new Map>()); 109 | 110 | const mounted: MutableRefObject = useRef(true); 111 | 112 | const call: 113 | | ReducedStatefulAsyncFunction 114 | | StatefulAsyncFunction = useMemo((): 115 | | ReducedStatefulAsyncFunction 116 | | StatefulAsyncFunction => { 117 | const call: 118 | | ReducedStatefulAsyncFunction 119 | | StatefulAsyncFunction = (async ( 120 | ...args: A 121 | ): Promise => { 122 | const id: string = reducer(...args); 123 | 124 | // Pending 125 | 126 | if (mounted.current) { 127 | setState({ 128 | error: undefined, 129 | id, 130 | state: State.Pending, 131 | value: undefined, 132 | }); 133 | } 134 | 135 | try { 136 | const aValue: T = await asyncFunction(...args); 137 | 138 | // Fulfilled 139 | if (mounted.current) { 140 | setState({ 141 | error: undefined, 142 | id, 143 | state: State.Fulfilled, 144 | value: aValue, 145 | }); 146 | } 147 | return aValue; 148 | } catch (e) { 149 | // Rejected 150 | if (mounted.current) { 151 | setState({ 152 | error: e, 153 | id, 154 | state: State.Rejected, 155 | value: undefined, 156 | }); 157 | } 158 | 159 | // If we are explicitly told to throw an error, do so. 160 | if (options.throwError === true) { 161 | throw e; 162 | } 163 | 164 | // If we are not explicitly told to throw an error, return undefined. 165 | return; 166 | } 167 | }) as 168 | | ReducedStatefulAsyncFunction 169 | | StatefulAsyncFunction; 170 | 171 | // Assign the state to the async function. 172 | if (reducer === DEFAULT_REDUCER) { 173 | Object.assign(call, state.get('_')); 174 | } else { 175 | for (const [id, asyncFunctionState] of state.entries()) { 176 | (call as ReducedStatefulAsyncFunction)[ 177 | id 178 | ] = asyncFunctionState; 179 | } 180 | } 181 | 182 | return call; 183 | }, [asyncFunction, mounted, options.throwError, reducer, state]); 184 | 185 | useEffect((): VoidFunction => { 186 | return (): void => { 187 | mounted.current = false; 188 | }; 189 | }, []); 190 | 191 | return call; 192 | } 193 | -------------------------------------------------------------------------------- /src/utils/async-function-reducer.test.ts: -------------------------------------------------------------------------------- 1 | import AsyncFunctionState from '../types/async-function-state'; 2 | import State from '../types/state'; 3 | import asyncFunctionReducer from './async-function-reducer'; 4 | 5 | type AFS = AsyncFunctionState; 6 | 7 | const INITIAL_STATE: Map = new Map(); 8 | 9 | describe('asyncFunctionReducer', (): void => { 10 | it('should add a state for an id', (): void => { 11 | const TEST_ID = 'test id'; 12 | const TEST_VALUE = 'test value'; 13 | const reducedStateMap: Map = asyncFunctionReducer( 14 | INITIAL_STATE, 15 | { 16 | error: undefined, 17 | id: TEST_ID, 18 | state: State.Fulfilled, 19 | value: TEST_VALUE, 20 | }, 21 | ); 22 | expect(reducedStateMap.has(TEST_ID)).toBe(true); 23 | const reducedState: AFS = reducedStateMap.get(TEST_ID) as AFS; 24 | expect(reducedState.error).toBeUndefined(); 25 | expect(reducedState.value).toBe(TEST_VALUE); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/utils/async-function-reducer.ts: -------------------------------------------------------------------------------- 1 | import AsyncFunctionState from '../types/async-function-state'; 2 | 3 | export type Action = AsyncFunctionState & HasId; 4 | 5 | interface HasId { 6 | id: string; 7 | } 8 | 9 | export default function asyncFunctionReducer( 10 | asyncFunctionStateMap: Map>, 11 | { id, ...asyncFunctionState }: Action, 12 | ): Map> { 13 | const newAsyncFunctionStateMap: Map< 14 | string, 15 | AsyncFunctionState 16 | > = new Map>(asyncFunctionStateMap); 17 | newAsyncFunctionStateMap.set(id, asyncFunctionState); 18 | return newAsyncFunctionStateMap; 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "allowSyntheticDefaultImports": true, 5 | "allowUmdGlobalAccess": false, 6 | "allowUnreachableCode": false, 7 | "allowUnusedLabels": false, 8 | "alwaysStrict": true, 9 | "checkJs": false, 10 | "declaration": true, 11 | "declarationDir": "dist", 12 | "declarationMap": false, 13 | "downlevelIteration": true, 14 | "esModuleInterop": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "inlineSourceMap": false, 17 | "jsx": "react", 18 | "lib": ["dom", "es5", "es2015.promise", "es2017"], 19 | "module": "commonjs", 20 | "moduleResolution": "node", 21 | "noFallthroughCasesInSwitch": true, 22 | "noImplicitAny": true, 23 | "noImplicitReturns": true, 24 | "noImplicitThis": true, 25 | "noUnusedLocals": true, 26 | "noUnusedParameters": true, 27 | "outDir": "dist", 28 | "removeComments": true, 29 | "resolveJsonModule": true, 30 | "rootDir": "src", 31 | "skipLibCheck": false, 32 | "sourceMap": true, 33 | "strict": true, 34 | "strictBindCallApply": true, 35 | "strictFunctionTypes": true, 36 | "strictNullChecks": true, 37 | "strictPropertyInitialization": true, 38 | "suppressImplicitAnyIndexErrors": false, 39 | "target": "es5" 40 | }, 41 | "exclude": [ 42 | "build", 43 | "coverage", 44 | "dist", 45 | "jest", 46 | "node_modules", 47 | "**/test-utils/*.ts", 48 | "**/test-utils/*.tsx", 49 | "**/*.test.ts", 50 | "**/*.test.tsx" 51 | ], 52 | "include": ["src/**/*"] 53 | } 54 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const configureWebpack = require('configure-webpack'); 2 | 3 | module.exports = configureWebpack({ typescript: true }); 4 | --------------------------------------------------------------------------------