├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── babel.config.js ├── jest.config.js ├── other └── turtle.png ├── package.json ├── src ├── Asyncs.test.ts ├── Asyncs.ts ├── components │ ├── AsyncViewContainer.test.tsx │ └── AsyncViewContainer.tsx ├── helpers.test.ts ├── helpers.ts ├── hooks │ ├── useAsyncData.test.tsx │ ├── useAsyncData.ts │ ├── useAsyncTask.test.tsx │ ├── useAsyncTask.ts │ ├── useManyAsyncTasks.test.tsx │ ├── useManyAsyncTasks.ts │ └── useStopRunawayEffect.ts ├── index.ts └── jest-dom.d.ts ├── testsSetup.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | lib/** 2 | node_modules/** -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "jest/globals": true, 6 | "node": true 7 | }, 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:jest/recommended", 12 | "prettier", 13 | "prettier/@typescript-eslint", 14 | "react-app" 15 | ], 16 | "parser": "@typescript-eslint/parser", 17 | "parserOptions": { 18 | "project": "./tsconfig.json" 19 | }, 20 | "plugins": ["@typescript-eslint", "jest"], 21 | "rules": { 22 | "@typescript-eslint/explicit-function-return-type": [ 23 | "warn", 24 | { "allowExpressions": true } 25 | ] 26 | }, 27 | "settings": { 28 | "react": { 29 | "version": "detect" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /lib 13 | 14 | # misc 15 | .DS_Store 16 | 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | 21 | # editors 22 | .vscode 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '12' 4 | sudo: false 5 | if: tag IS blank 6 | script: 7 | - yarn lint 8 | - yarn test 9 | - yarn build 10 | after_success: yarn coverage 11 | deploy: 12 | provider: npm 13 | email: carlos.gines.fuster@gmail.com 14 | api_key: 15 | secure: Xu3haiiWxhBBKpDK+gw+jJmBS+wNQ/1EJB8iLyAJVfClpYp6+tP4Lkosyzy+AHiRri1kwAI5JVwxYAblcU+wqZinRVMkABWCa/8lBAI066CT7yo6BMgPNj8ju75KksoHo2FrHdgsgn8oN6mhsWzaxx6lSD+0Y6kNnXnUaR8T9VWKbnms75iYVxbktNC3eTL4zrcOT4Bh4lW1AYlwt1xqvt9N9xtPh2g0WV4kKF67jiycxfU7E6MTTo2Y5Hbi/p8RScGrXvEw+3rr4U+xB7JgQq3WpUN/Td6+mbYh/xD3tD0LWnt+1rLX9iDK3Aq8m0ULxw8Wt6c1oahPCSgwCCOGMsoFzdRdkOQYPbvoXmbRhAWwyZsKNQy5dISpFZPMPLX2CLIqBaNXofg0Oeo+sMMq9pURXR4z+O1Zi/eGVRFREtA3nP3Oy4jy5D86vhkUP90QU8q6yYWAvqttgA4zYqgfngk758vbVu7eETxre41eUj3Gs49wW99UzsiM+MFLqaMJesZrKGxYSZ2retkMnocpWuJkAMt8L2ZWjCslX/GIEdmpb/KH4KVPLB3oS/HjN1vEuc/o2OQwCzPBWVEeC7i7/MKwPsHQjhfQzBN3O66+ccIj+c0b6mpE3fT636dVL30ZoippNSIbMT/nt8ni4ybQuE10XDoGgRMyR+X0FWo1xlA= 16 | on: 17 | branch: master 18 | skip_cleanup: true 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.14.0 (Oct 29, 2019) 2 | 3 | ### 🚀 New features 4 | 5 | - New check to stop runaway effects. If you use `useAsyncData` hook unproperly, causing an infinite lop, now you will get an error explaining what happened and how to address it. 6 | 7 | ### 💥 Breaking changes 8 | 9 | - `render` method has been moved to an `Async` class method. 10 | - `render(someAsync, { ... }` becomes `someAsync.render({ ... })` 11 | - Now all `Async` classes use the generic `Payload` param: 12 | 13 | ```typescript 14 | export type Async = 15 | | InitAsync 16 | | InProgressAsync 17 | | SuccessAsync 18 | | ErrorAsync; 19 | ``` 20 | 21 | ## 0.13.3 (Oct 28, 2019) 22 | 23 | ### 🏠 Internal 24 | 25 | - Updated dependencies. 26 | - Migrated from `npm` to `yarn` 27 | 28 | ## 0.13.2 (May 3, 2019) 29 | 30 | ### 🐛 Bug fixes 31 | 32 | - Fixed clean up on `useAsyncTask` and `useManyAsyncTasks`. 33 | 34 | ## 0.13.1 (May 3, 2019) 35 | 36 | ### 🐛 Bug fixes 37 | 38 | - Fixed typing for `getPayload`, and added tests for it. 39 | 40 | ### 📝 Documentation 41 | 42 | - Minimal updates to README. 43 | 44 | ## 0.13.0 (Apr 24, 2019) 45 | 46 | ### 🚀 New features 47 | 48 | - New `useManyAsyncTasks` hook. It works exactly the same way `useAsyncTask` does, but this hook can be used to track multiple async tasks of the same type. Instead of returning an `AsyncTask`, it returns an `AsyncTask` getter. Use any key as input to obtain the associated `AsyncTask`. 49 | 50 | ### 💥 Breaking changes 51 | 52 | - `useAsyncTask` tasks don't get aborted when triggered multiple times or on component unmounting, only via the provided `abort` function. State updates still get cancelled on component unmounting. 53 | - `useAsyncData` and `useAsyncTask` now return an object (an `AsyncData` or an `AsyncTask`) instead of a tuple. These objects are an `Async` including extra methods, like `refresh`, `trigger` or `abort`. 54 | - Many helper methods have been moved to class methods or constructors. 55 | - `newInit(aborted)` becomes `new InitAsync(aborted)`... 56 | - `isInit(someAsync)` becomes `someAsync.isInit()`... 57 | - `getPayload(someAsync)` becomes `someAsync.getPayload()`. 58 | - `getError(someAsync)` becomes `someAsync.getError()`. 59 | 60 | ### 📝 Documentation 61 | 62 | - Updated and extended README. 63 | 64 | ## 0.12.1 (Mar 31, 2019) 65 | 66 | ### 📝 Documentation 67 | 68 | - Fixed outdated intro in README. 69 | 70 | ## 0.12.0 (Mar 31, 2019) 71 | 72 | This version is a major API update, to better match how the library is used. Main change: we have split `useAsyncTask` hook in 2: `useAsyncData` for data fetching use cases and `useAsyncTask` for data mutation use cases. See the docs on README.md to better understand them. 73 | 74 | ### 🚀 New features 75 | 76 | - New `useAsyncData` hook. Similar to previous hook, with subtle differences. It runs a `getData` async task **as an effect by default**. I can be re-triggered and reset manually, and it provides a `disable` option to keep the effect —and manual triggers— inactive. 77 | 78 | ### 💥 Breaking changes 79 | 80 | - `useAsyncTask` is different to previous version. I receives a `getTask` function that provides the `AbortSignal` and returns a function with any args that will be the async task. It is **never run as an effect**. You can only trigger it manually with the returned "trigger" function, and **you can provide any args to the task via the trigger function** args (they are forwarded). 81 | - `task` helper method renamed to `triggerTask`. 82 | 83 | ### 📝 Documentation 84 | 85 | - Updated README parts related to hooks. Minors everywhere in README too. 86 | 87 | ### 🏠 Internal 88 | 89 | - Migrated tests to Typescript. 90 | 91 | ## 0.11.0 (Mar 22, 2019) 92 | 93 | ### 💥 Breaking changes 94 | 95 | - The `AbortSignal` that `useAsyncTask` provides as input parameter of the input function, is now optional. This is because its availability depends on browser compatibility. 96 | 97 | ### 🐛 Bug fixes 98 | 99 | - Fixed broken IE compatibility because of `AbortController`. 100 | - Fixed undesired transition from `SuccessAsync` to `InProgressAsync` when the task from `useAsyncTask` is triggered as an effect. Now it transitions to an invalidated `SuccessAsync`. 101 | 102 | ### 📝 Documentation 103 | 104 | - Updated `useAsyncTask` in API Reference in README. 105 | 106 | ## 0.10.3 (Mar 21, 2019) 107 | 108 | ### 🐛 Bug fixes 109 | 110 | - Fixed bug in `AsyncViewContainer` return type, preventing Typescript compilation. 111 | 112 | ### 📝 Documentation 113 | 114 | - Added `useAsyncTask` to API Reference in README. 115 | 116 | ## 0.10.2 (Mar 19, 2019) 117 | 118 | ### 🚀 New features 119 | 120 | - Typescript source files included in package bundle to enable "Go to Definition" inspection. 121 | 122 | ### 📝 Documentation 123 | 124 | - README minimal updates. Added the turtle! 🐢 125 | - CHANGELOG style enhanced. 126 | 127 | ### 🏠 Internal 128 | 129 | - Added more tests to `render` function. 130 | - Dropped source maps in package bundle. 131 | 132 | ## 0.10.1 (Mar 18, 2019) 133 | 134 | ### 📝 Documentation 135 | 136 | - README badges updated. 137 | 138 | ### 🏠 Internal 139 | 140 | - Added Travis CI and coveralls coverage report. 141 | 142 | ## 0.10.0 (Mar 17, 2019) 143 | 144 | This is a relevant new verion. We have reached a more stable version of `useAsyncTask` hook: it finally meets `exhaustive-deps` rule, avoiding some bugs and making its API more intuitive. There are also new features regarding aborting tasks and race conditions. We have also added tests. 145 | 146 | ### 🚀 New features 147 | 148 | - `useAsyncTask` handles race condition prevention. 149 | - `useAsyncTask` provides an `AbortSignal` at the input function to make it abortable. 150 | - Added `aborted` substate to `InitAsync`. Adapted helper methods, `render` and `useAsyncTask` accordingly. 151 | 152 | ### 💥 Breaking changes 153 | 154 | - Updated `useAsyncTask`: 155 | - New boolean `triggerAsEffect` option instead of `autoTriggerWith` to trigger the async task automatcally. 156 | - Removed `onChange` option. 157 | 158 | ### 🐛 Bug fixes 159 | 160 | - Fixed `errorRender` not being rendered at `AsyncViewContainer` in some cases. 161 | - Fixed `AsyncViewContainer` properly accepting `null` as render props. 162 | 163 | ### 📝 Documentation 164 | 165 | - README updated. 166 | 167 | ### 🏠 Internal 168 | 169 | - Added tests. 170 | 171 | ## 0.9.0 (Mar 7, 2019) 172 | 173 | ### 🚀 New features 174 | 175 | - Prevented racing conditions in `useAsyncData` when triggering an Async task times. 176 | - Prevented undesired state updates from `useAsyncData` when reseting an `InProgress` Async. 177 | 178 | ### 💥 Breaking changes 179 | 180 | - Renamed `useAsyncData` to `useAsyncTask` 181 | - The `trigger` function returned by `useAsyncData` and the helper method `task` now return a promise of the resulting `Async` instead of `void`. 182 | 183 | ### 🐛 Bug fixes 184 | 185 | - Deleted `useAsyncData` broken infinite loops detection in auto-trigger effect. We might add a new one in the future. 186 | 187 | ### 📝 Documentation 188 | 189 | - README updated. 190 | 191 | ## 0.8.0 (Mar 4, 2019) 192 | 193 | Big refactor for the sake of simplicity and understandability. Significantly revamped README.md to to explain the library more clearly. 194 | 195 | ### 🚀 New features 196 | 197 | - `useAsyncData` now detects, prevents and logs (`console.error`) infinite loops caused by inappropriate dependencies for the auto-trigger effect. 198 | 199 | ### 💥 Breaking changes 200 | 201 | - `useAsyncData`: always returns new trigger and reset functions. Dependencies in 3rd arg only affect the auto-trigger effect now. 202 | - `AsyncViewContainer`: renamed most props to be more consistent and descriptive. 203 | - `render`: args 2 to 5 (render functions) have become just one single arg: an object with one optional property per render function. 204 | - Helper functions: 205 | - Now exported directly at top level, instead of under the `async` object. 206 | - Renamed many of them, and merged some of them into one. 207 | 208 | ### 📝 Documentation 209 | 210 | - README updated. 211 | 212 | ## 0.7.1 (Mar 1, 2019) 213 | 214 | ### 🚀 New features 215 | 216 | - `useAsyncData` hook now accepts a 3rd parameter as dependencies for the hook, overriding the first 2 parameters as dependencies. 217 | - `AsyncViewContainer` render props accept `null` to render nothing. 218 | 219 | ### 📝 Documentation 220 | 221 | - README updated. 222 | 223 | ## 0.7.0 (Feb 28, 2019) 224 | 225 | ### 💥 Breaking changes 226 | 227 | - `useAsyncData` hook now includes all inputs as dependencies. Be specially careful if you use `autoTriggerWith`, since it might trigger `getData` on every render. 228 | 229 | ### 🚀 New features 230 | 231 | - `render` accepts `null` as parameter to render nothing at the corresponding async state render. 232 | 233 | ### 📝 Documentation 234 | 235 | - README updated. 236 | 237 | # Previous Releases 238 | 239 | Not documented: too early stages. 240 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2019 Carlos Gines 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⚠️ Deprecation warning ⚠️ 2 | 3 | This project is deprecated,and it will no longer be maintained 4 | 5 |
6 |

react-async-utils

7 | turtle 8 | 9 |

10 | npm 11 | Travis (.com) 12 | Coveralls github 13 | Snyk Vulnerabilities for npm package 14 | GitHub 15 |

16 |
17 | 18 | Collection of utils to work with asynchronous data and tasks in React in a more declarative way. Featuring `useAsyncData` and `useAsyncTask` hooks for this purpose. It is delightful to use with TypeScript, but it can equally be used with JavaScript. 19 | 20 | # Table of Contents 21 | 22 | 23 | 24 | 25 | - [The difficulties](#the-difficulties) 26 | - [Data structure](#data-structure) 27 | - [Features (React and non-React)](#features-react-and-non-react) 28 | - [This solution](#this-solution) 29 | - [Data structure](#data-structure-1) 30 | - [Features (React and non-React)](#features-react-and-non-react-1) 31 | - [Installation](#installation) 32 | - [The new `Async` data concept](#the-new-async-data-concept) 33 | - [The 4 basic states of `Async` data](#the-4-basic-states-of-async-data) 34 | - [The hooks](#the-hooks) 35 | - [`useAsyncData` hook](#useasyncdata-hook) 36 | - [`useAsyncTask` hook](#useasynctask-hook) 37 | - [Rendering `Async` data](#rendering-async-data) 38 | - [render](#render) 39 | - [AsyncViewContainer](#asyncviewcontainer) 40 | - [API Reference (WIP)](#api-reference-wip) 41 | - [InitAsync](#initasync) 42 | - [InProgressAsync](#inprogressasync) 43 | - [SuccessAsync](#successasync) 44 | - [ErrorAsync](#errorasync) 45 | - [Async](#async) 46 | - [`isInit`](#isinit) 47 | - [`isInProgress`](#isinprogress) 48 | - [`isSuccess`](#issuccess) 49 | - [`isError`](#iserror) 50 | - [`isInProgressOrInvalidated`](#isinprogressorinvalidated) 51 | - [`isAborted`](#isaborted) 52 | - [`getPayload`](#getpayload) 53 | - [`getError`](#geterror) 54 | - [Hooks](#hooks) 55 | - [`useAsyncData`](#useasyncdata) 56 | - [`useAsyncTask`](#useasynctask) 57 | - [`useManyAsyncTasks`](#usemanyasynctasks) 58 | - [Contributing](#contributing) 59 | - [LICENSE](#license) 60 | 61 | 62 | 63 | # The difficulties 64 | 65 | ## Data structure 66 | 67 | Dealing with asynchronous data or tasks is usually an imperative process, harder to express in a declarative manner, such as React promotes. It usually results in using a combination of variables/properties to keep track of the possible states: 68 | 69 | ```javascript 70 | let loading; 71 | let data; 72 | let error; 73 | // Maybe more... 74 | ... 75 | ``` 76 | 77 | This a somehow complex construct for such an ubiquitous case. It can lead to verbose code, even more when dealing with multiple pieces of async data at the same time. Some of these combinations don't even make sense (`loading === true && error !== undefined`?). It can feel awkward to follow this pattern. 78 | 79 | ## Features (React and non-React) 80 | 81 | You want to stay up to date with the state of art in our domain (Hooks, Concurrent Mode, Suspense...). You also want to take care of the more subtle requirements, like race conditions, or aborting. And you want to do it the right way. And that is not trivial. 82 | 83 | # This solution 84 | 85 | ## Data structure 86 | 87 | The base of this library is "[making impossible states impossible](https://blog.kentcdodds.com/make-impossible-states-impossible-cf85b97795c1)" for async data, and building rich abstractions around it. 88 | 89 | We do not separate the data itself from its asynchronous state, we consider it an intrinsic part of its nature. And so we put it all together as a new data type consistent with this async nature. 90 | 91 | We named this data type `Async`. 92 | 93 | ```typescript 94 | let asyncPerson: Async; 95 | ``` 96 | 97 | It can be considered the declarative counterpart of a `Promise`. 98 | 99 | This new data type allows us to create some powerful abstractions, like the `useAsyncData` custom hook 100 | 101 | ```typescript 102 | const asyncPerson = useAsyncData(getPersonPromise); 103 | ``` 104 | 105 | which we will explain further down. 106 | 107 | ## Features (React and non-React) 108 | 109 | Our utils use the latest **stable** React capabilities to make working properly with async data and tasks **easy and direct**. They also take care of stuff like race conditions, cleaning up and easy aborting. 110 | 111 | # Installation 112 | 113 | ```bash 114 | npm install react-async-utils 115 | ``` 116 | 117 | # The new `Async` data concept 118 | 119 | We are going to deal with async data in all of its possible states as a single entity. This entity includes all possible states and related data within it, in an ordered (and type-safe) manner. 120 | 121 | ## The 4 basic states of `Async` data 122 | 123 | We consider any `Async` data can exist in one of this 4 states: 124 | 125 | - **INIT**: nothing has happened yet. This is time 0 of our async process: 126 | 127 | ```typescript 128 | interface InitAsync { 129 | progress: Progress.Init; 130 | } 131 | ``` 132 | 133 | - **IN PROGRESS**: our async process is happening. We are waiting for its outcome: 134 | 135 | ```typescript 136 | interface InProgressAsync { 137 | progress: Progress.InProgress; 138 | } 139 | ``` 140 | 141 | - **SUCCESS**: our async process is successfully completed. The actual data will be available in its **payload**: 142 | 143 | ```typescript 144 | interface SuccessAsync { 145 | progress: Progress.Success; 146 | payload: Payload; 147 | } 148 | ``` 149 | 150 | - **ERROR**: our async process failed. There will be an **error**, the cause of this failure: 151 | 152 | ```typescript 153 | interface ErrorAsync { 154 | progress: Progress.Error; 155 | error: Error; 156 | } 157 | ``` 158 | 159 | And so, an `Async` data encapsulates the 4 states of a piece of data along the async process within a single data type: 160 | 161 | ```typescript 162 | export type Async = 163 | | InitAsync 164 | | InProgressAsync 165 | | SuccessAsync 166 | | ErrorAsync; 167 | ``` 168 | 169 | This data type is the base of our library. Take your time to understand it, and we will be able to do great things with it. 170 | 171 | ## The hooks 172 | 173 | This library features 2 main hooks: `useAsyncData` and `useAsyncTask`: 174 | 175 | ### `useAsyncData` hook 176 | 177 | A powerful abstraction to manage querying or fetching data in a declarative way. It takes care of race conditions and it can get aborted. It looks like this: 178 | 179 | ```typescript 180 | const asyncPerson = useAsyncData(getPersonPromise); 181 | ``` 182 | 183 | - **`getPersonPromise`**: input function to fetch data. It returns a `Promise` that resolves to the desired data. 184 | - **`asyncPerson`**: it is our `Async` data. It will be in the INIT state at the beginning, but will start getting updated as the data fetching task is triggered. 185 | 186 | This hook will run our data fetching task as an effect, so it will happen automatically after the first render. This effect will update `asyncPerson` state according to the state of the `Promise` returned by `getPersonPromise`. 187 | 188 | ⚠️ Be careful, this hook can cause infinite loops. The input function is a dependency of the effect. You want to use `React.useCallback` if needed to keep its identity and prevent these loops: 189 | 190 | ```typescript 191 | const asyncPerson = useAsyncData( 192 | React.useCallback(() => getPersonPromise(personId), [personId]), 193 | ); 194 | ``` 195 | 196 | You can read more details of `useAsyncData` hook at the [API Reference](#useasyncdata). 197 | 198 | ### `useAsyncTask` hook 199 | 200 | Similar to `useAsyncData`, this hook is used to manage posting or mutating data in a more declarative way. It can be aborted. 201 | 202 | ```typescript 203 | const asyncSubmitValues = useAsyncTask(() => submitValues); 204 | 205 | const triggerButton = ( 206 | 207 | ); 208 | ``` 209 | 210 | - **`submitValues`**: input function that accepts input arguments and returns a `Promise` that resolves to the result of the operation. 211 | - **`asyncSubmitValues`**: it is our `Async` data. It will be in the INIT state at the beginning, but will start getting updated once the async task is triggered. 212 | - **`asyncSubmit.trigger`**: a function that triggers the async task. When invoked, it will call `submitValues` with the same arguments it receives, and it will update `asyncSubmit` state according to the state of the `Promise` returned by `submitValues`. 213 | 214 | Unlike `useAsyncData`, this task will not be triggered as an effect, you will trigger it with the `trigger` funtion, and you can provide it with any parameters. 215 | 216 | See the [API Reference](#useasynctask) for more details. 217 | 218 | ## Rendering `Async` data 219 | 220 | ### render 221 | 222 | A good option is the `render` class method. You can provide a specific render method per state. The corresponding one will be used: 223 | 224 | ```tsx 225 | asyncPerson.render({ 226 | init: () =>

INIT state render. Nothing happened yet.

, 227 | inProgress: () => ( 228 |

IN PROGRESS state render. We are fetching our Person.

229 | ), 230 | success: person =>

SUCCESS state render. Please welcome {person.name}!

, 231 | error: error => ( 232 |

ERROR state render. Something went wrong: {error.message}

233 | ), 234 | }); 235 | ``` 236 | 237 | ### AsyncViewContainer 238 | 239 | Another option is the `AsyncViewContainer` component: 240 | 241 | ```tsx 242 | import { AsyncViewContainer, getPayload } from 'react-async-utls'; 243 | 244 | function MyComponent({ asyncPerson }) { 245 | person = getPayload(asyncPerson); 246 | return ( 247 | 'Loading person...'} 250 | errorRender={error => `Something went wrong: ${error.message}`} 251 | > 252 | {person ? : 'No Data'} 253 | 254 | ); 255 | } 256 | ``` 257 | 258 | Apart from its children, it will render the render method of the corresponding `Async` data state. 259 | 260 | BONUS: `AsyncViewContainer` accepts an array at the `asyncData` prop : 261 | 262 | ```tsx 263 | function MyComponent({ asyncPerson }) { 264 | return ( 265 | 'Loading stuff...'} 268 | errorRender={errors => errors.map(error => error.message).join(' AND ')} 269 | > 270 | // ... 271 | 272 | ); 273 | } 274 | ``` 275 | 276 | It will render the corresponding render method if _any_ `Async` data is on that state. 277 | 278 | # API Reference (WIP) 279 | 280 | Work in progress. 281 | 282 | ## InitAsync 283 | 284 | ```typescript 285 | class InitAsync { 286 | public progress: Progress.Init; 287 | public aborted?: boolean; 288 | public constructor(aborted?: boolean); 289 | } 290 | ``` 291 | 292 | Represents the INIT state **and** the ABORTED sub-state. 293 | 294 | ## InProgressAsync 295 | 296 | ```typescript 297 | class InProgressAsync { 298 | public progress: Progress.InProgress; 299 | public constructor(); 300 | } 301 | ``` 302 | 303 | Represents the IN PROGRESS state. 304 | 305 | ## SuccessAsync 306 | 307 | ```typescript 308 | class SuccessAsync { 309 | public progress: Progress.Success; 310 | public payload: Payload; 311 | public invalidated?: boolean; 312 | public constructor(aborted?: boolean); 313 | } 314 | ``` 315 | 316 | Represents the SUCCESS state, with its corresponding payload **and** the INVALIDATED sub-state (payload outdated, new one in progress). 317 | 318 | ## ErrorAsync 319 | 320 | ```typescript 321 | class ErrorAsync { 322 | public progress: Progress.Error; 323 | public error: Error; 324 | public constructor(error: Error); 325 | } 326 | ``` 327 | 328 | Represents the ERROR state, with its corresponding error. 329 | 330 | ## Async 331 | 332 | All `Async` objects have these methods: 333 | 334 | ### `isInit` 335 | 336 | ```typescript 337 | public isInit(): this is InitAsync 338 | ``` 339 | 340 | - **@returns** `true` if async object is in INIT state and act as type guard. 341 | 342 | ### `isInProgress` 343 | 344 | ```typescript 345 | public isInProgress(): this is InProgressAsync 346 | ``` 347 | 348 | - **@returns** `true` if async object is in IN PROGRESS state and act as type guard. 349 | 350 | ### `isSuccess` 351 | 352 | ```typescript 353 | public isSuccess(): this is SuccessAsync 354 | ``` 355 | 356 | - **@returns** `true` if async object is in SUCCESS state and act as type guard. 357 | 358 | ### `isError` 359 | 360 | ```typescript 361 | public isError(): this is ErrorAsync 362 | ``` 363 | 364 | - **@returns** `true` if async object is in ERROR state and act as type guard. 365 | 366 | ### `isInProgressOrInvalidated` 367 | 368 | ```typescript 369 | public isInProgressOrInvalidated(): this is InProgressAsync | SuccessAsync 370 | ``` 371 | 372 | - **@returns** `true` if async object is in IN PROGRESS state or INVALIDATED sub-state and act as type guard. 373 | 374 | ### `isAborted` 375 | 376 | ```typescript 377 | public isAborted(): this is InitAsync 378 | ``` 379 | 380 | - **@returns** `true` if async object is in ABORTED sub-state and act as type guard. 381 | 382 | ### `getPayload` 383 | 384 | ```typescript 385 | public getPayload(): Payload | undefined 386 | ``` 387 | 388 | - **@returns** corresponding payload (generic) if async object is in SUCCESS state or `undefined` otherwise. 389 | 390 | ### `getError` 391 | 392 | ```typescript 393 | public getError(): Error | undefined 394 | ``` 395 | 396 | - **@returns** corresponding `Error` if async object is in ERROR state or `undefined` otherwise. 397 | 398 | ### `render` 399 | 400 | ```typescript 401 | public render({ 402 | init?: (aborted?: boolean) => ReactNode; 403 | inProgress?: () => ReactNode; 404 | success?: (payload: Payload, invalidated?: boolean) => ReactNode; 405 | error?: (error: Error) => ReactNode; 406 | }): ReactNode 407 | ``` 408 | 409 | - **@returns** the `ReactNode` corresponding to the render function of the async object current state, or `null` if not provided. 410 | 411 | ## Hooks 412 | 413 | ### `useAsyncData` 414 | 415 | ```typescript 416 | function useAsyncData( 417 | getData: (singal?: AbortSignal) => Promise, 418 | { 419 | disabled, 420 | onSuccess, 421 | onError, 422 | }: { 423 | disabled?: boolean; 424 | onSuccess?: (payload: Payload) => void; 425 | onError?: (error: Error) => void; 426 | }, 427 | ): AsyncData; 428 | 429 | type AsyncData = Async & { 430 | refresh: () => void; 431 | }; 432 | ``` 433 | 434 | This hook is suitable for handling any kind of querying or data fetching. It takes care of race conditions and it cleans up on component unmount. 435 | 436 | ⚠️ Be careful, all input functions (`getData`, `onSuccess`, `onError`) are dependencies of the effect it uses. You can create infinite loops if you do not hand them carefully. Wrap the input functions in `React.useCallback` if needed to prevent these infinite loops. **But don't worry too much**: we produce an error with detailed info in case this happens. 437 | 438 | Definition: 439 | 440 | - **@param `getData`** 441 | 442 | _**You want to perform your fetch here**_. This input function is the async data fetching task that will be carried out. It must return a `Promise` that resolves to the desired data. 443 | 444 | It can use the `AbortSignal` that the hook provides (when browser supports it) if you want to make your task [abortable](https://developers.google.com/web/updates/2017/09/abortable-fetch). 445 | 446 | - **@param `options.disabled`** 447 | 448 | While false (default), your task will be run as an effect (inside a `useEffect` hook). If true, your task will not be run as an effect, it will always return an `InitAsync`. 449 | 450 | - **@param `options.onSuccess`** 451 | 452 | Callback function that will be called when the task reaches the SUCCESS state. 453 | 454 | - **@param `options.onError`** 455 | 456 | Callback function that will be called when the task reaches the ERROR state. 457 | 458 | - **@returns** 459 | 460 | The `AsyncData`, which is the `Async` corresponding to the current state of the data, extended with this function: 461 | 462 | - **refresh:** function that can be used to trigger the fetch manually (i.e. from a "Refresh" button). 463 | 464 | ### `useAsyncTask` 465 | 466 | ```typescript 467 | function useAsyncTask( 468 | getTask: (singal?: AbortSignal) => (...args: Args) => Promise, 469 | { 470 | onSuccess, 471 | onError, 472 | }: { 473 | onSuccess?: (payload: Payload) => void; 474 | onError?: (error: Error) => void; 475 | }, 476 | ): AsyncTask; 477 | 478 | type AsyncTask = Async & { 479 | trigger: (...args: Args) => Promise>; 480 | abort: () => void; 481 | }; 482 | ``` 483 | 484 | This hook is suitable for handling any kind of data posting or mutation task. On component unmount it cleans up, but it does not abort flying requests (this can be done with the provided function). 485 | 486 | If triggered multiple times in a row: 487 | 488 | - All triggered tasks will happen. 489 | - The returned `Aync` will only track the state of the last triggered tasks. See [`useManyAsyncTasks`](#usemanyasynctasks) if you want to trigger and track the state of many tasks. 490 | - `abort` will abort all existing tasks. 491 | 492 | Definition: 493 | 494 | - **@param `getTask`** 495 | 496 | This input function returns the async task that will be carried out. The returned task can have any input arguments, that will be provided when the task is triggered. The task must return a `Promise` that can resolve to the result of the operation (i.e. the new ID of a posted item) or to void. 497 | 498 | It can use the `AbortSignal` that the hook provides (when browser supports it) if you want to make your task [abortable](https://developers.google.com/web/updates/2017/09/abortable-fetch). 499 | 500 | - **@param `options.onSuccess`** 501 | 502 | Callback function that will be called when the task reaches the SUCCESS state. 503 | 504 | - **@param `options.onError`** 505 | 506 | Callback function that will be called when the task reaches the ERROR state. 507 | 508 | - **@returns** 509 | 510 | The `AsyncTask`, which is the `Async` corresponding to the current state of the task, extended with these functions: 511 | 512 | - **trigger:** function that triggers the task (i.e. from a "Submit" button). It forwards its args to the task that you provided to the hook, and it returns a `Promise` of the `Async` result. You generally won't use this returned `Async`, it is a escape hatch for some cases. 513 | - **abort:** function that aborts the task, setting the `Async` back to the INIT state, and as ABORTED if it was IN PROGRESS or INVALIDTED. 514 | 515 | ### `useManyAsyncTasks` 516 | 517 | ```typescript 518 | function useManyAsyncTasks( 519 | getTask: (singal?: AbortSignal) => (...args: Args) => Promise, 520 | { 521 | onSuccess, 522 | onError, 523 | }: { 524 | onSuccess?: (payload: Payload) => void; 525 | onError?: (error: Error) => void; 526 | }, 527 | ): (key: any) => AsyncTask; 528 | ``` 529 | 530 | It works exactly the same way `useAsyncTask` does, but this hook can be used to track multiple async tasks of the same type. Instead of returning an `AsyncTask`, it returns an `AsyncTask` getter. Use any key as input to obtain the associated `AsyncTask`. 531 | 532 | # Contributing 533 | 534 | Open to contributions! 535 | 536 | # LICENSE 537 | 538 | MIT 539 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-react', 5 | '@babel/preset-typescript', 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // const { defaults } = require('jest-config'); 2 | module.exports = { 3 | setupFilesAfterEnv: ['./testsSetup.ts'], 4 | testPathIgnorePatterns: [ 5 | // ...defaults.testPathIgnorePatterns, 6 | './node_modules/', 7 | './lib/', 8 | ], 9 | }; 10 | -------------------------------------------------------------------------------- /other/turtle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wild-lotus/react-async-utils/7a69ef129e64f1e41c00a44a78e540322173c961/other/turtle.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-async-utils", 3 | "version": "0.14.0", 4 | "description": "Collection of utils to work with asynchronous data and asynchronous tasks in React in a more declarative way.", 5 | "keywords": [ 6 | "async", 7 | "concurrentmode", 8 | "declarative", 9 | "fetch", 10 | "hooks", 11 | "promise", 12 | "react", 13 | "reacthooks", 14 | "reactjs", 15 | "suspense", 16 | "utils" 17 | ], 18 | "files": [ 19 | "lib", 20 | "!lib/**/*.test.*", 21 | "src", 22 | "!src/**/*.test.*" 23 | ], 24 | "main": "./lib/index.js", 25 | "typings": "./lib/index.d.ts", 26 | "dependencies": { 27 | "@types/node": "^12.11.7", 28 | "@types/react": "^16.9.11" 29 | }, 30 | "devDependencies": { 31 | "@babel/core": "^7.6.4", 32 | "@babel/preset-env": "^7.6.3", 33 | "@babel/preset-react": "^7.6.3", 34 | "@babel/preset-typescript": "^7.6.0", 35 | "@testing-library/jest-dom": "^4.2.0", 36 | "@testing-library/react": "^9.3.0", 37 | "@types/jest": "^24.0.20", 38 | "@types/react-dom": "^16.9.3", 39 | "@typescript-eslint/eslint-plugin": "^2.6.0", 40 | "@typescript-eslint/parser": "^2.6.0", 41 | "babel-eslint": "^10.0.3", 42 | "coveralls": "^3.0.7", 43 | "eslint": "^6.6.0", 44 | "eslint-config-prettier": "^6.5.0", 45 | "eslint-config-react-app": "^5.0.2", 46 | "eslint-plugin-flowtype": "^3.13.0", 47 | "eslint-plugin-import": "^2.18.2", 48 | "eslint-plugin-jest": "^23.0.2", 49 | "eslint-plugin-jsx-a11y": "^6.2.3", 50 | "eslint-plugin-react": "^7.16.0", 51 | "eslint-plugin-react-hooks": "^1.7.0", 52 | "jest": "^24.9.0", 53 | "react": "^16.11.0", 54 | "react-dom": "^16.11.0", 55 | "typescript": "~3.6.4" 56 | }, 57 | "peerDependencies": { 58 | "react": ">=16.8.0" 59 | }, 60 | "scripts": { 61 | "lint": "eslint . --ext \".ts,.tsx\"", 62 | "watch": "tsc --watch", 63 | "test": "jest", 64 | "coverage": "jest --coverage --coverageReporters=text-lcov | coveralls", 65 | "build": "tsc" 66 | }, 67 | "author": "Carlos Gines (https://twitter.com/_carlosgines)", 68 | "repository": "github:CarlosGines/react-async-utils", 69 | "license": "MIT", 70 | "browserslist": [ 71 | ">0.2%", 72 | "not dead", 73 | "not ie <= 11", 74 | "not op_mini all" 75 | ], 76 | "prettier": { 77 | "singleQuote": true, 78 | "trailingComma": "all" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Asyncs.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Async, 3 | InitAsync, 4 | InProgressAsync, 5 | SuccessAsync, 6 | ErrorAsync, 7 | } from './index'; 8 | 9 | interface TestPayload { 10 | data: string; 11 | } 12 | 13 | it('can access properly typed payload from success state Async', () => { 14 | const INPUT_PAYLOAD: TestPayload = { data: 'stlo0cca' }; 15 | const outputPayload: TestPayload | undefined = (new SuccessAsync( 16 | INPUT_PAYLOAD, 17 | ) as Async).getPayload(); 18 | expect(outputPayload).toBe(INPUT_PAYLOAD); 19 | }); 20 | 21 | it('can access empty payload from init state Async', () => { 22 | const outputPayload: TestPayload | undefined = (new InitAsync() as Async< 23 | TestPayload 24 | >).getPayload(); 25 | expect(outputPayload).toBe(undefined); 26 | }); 27 | 28 | it('can access error from error state Async', () => { 29 | const inputError = new Error(); 30 | const outputError: Error | undefined = (new ErrorAsync(inputError) as Async< 31 | TestPayload 32 | >).getError(); 33 | expect(outputError).toBe(inputError); 34 | }); 35 | 36 | it('can access empty error from error state Async', () => { 37 | const outputError: Error | undefined = (new InitAsync() as Async< 38 | TestPayload 39 | >).getError(); 40 | expect(outputError).toBe(undefined); 41 | }); 42 | 43 | describe('render', () => { 44 | it('returns `init` render result that provides `aborted` substate given an `InitAsync`', () => { 45 | const INIT_TEXT = 'INIT_zuscufhu'; 46 | const ABORTED_TEXT = 'ABORTED_jamnobof'; 47 | const renders = { 48 | init: (aborted?: boolean) => (aborted ? ABORTED_TEXT : INIT_TEXT), 49 | }; 50 | // const initResult = render(new InitAsync(), renders); 51 | const initResult = new InitAsync().render(renders); 52 | expect(initResult).toBe(INIT_TEXT); 53 | const abortedResult = new InitAsync(true).render(renders); 54 | expect(abortedResult).toBe(ABORTED_TEXT); 55 | }); 56 | 57 | it('returns `null` if no `init` render provided given an `InitAsync`', () => { 58 | const result = new InitAsync().render({}); 59 | expect(result).toBeNull(); 60 | }); 61 | 62 | it('returns `inProgress` render result given an `InProgressAsync`', () => { 63 | const TEXT = 'tanfozem'; 64 | const result = new InProgressAsync().render({ inProgress: () => TEXT }); 65 | expect(result).toBe(TEXT); 66 | }); 67 | 68 | it('returns `null` if no `inProgress` render provided given an `InProgressAsync`', () => { 69 | const result = new InProgressAsync().render({}); 70 | expect(result).toBeNull(); 71 | }); 72 | 73 | it('returns `success` render result that provides `payload` and `invalidated` substate given a `SuccessAsync`', () => { 74 | const SUCCESS_TEXT = 'SUCCESS_jacofduc'; 75 | const INVALIDATED_TEXT = 'INVALIDATED_ceszimem'; 76 | const PAYLOAD_TEXT = 'PAYLOAD_osulboci'; 77 | const renders = { 78 | success: (payload: T, invalidated?: boolean) => 79 | invalidated ? INVALIDATED_TEXT + payload : SUCCESS_TEXT + payload, 80 | }; 81 | const successResult = new SuccessAsync(PAYLOAD_TEXT).render(renders); 82 | expect(successResult).toBe(SUCCESS_TEXT + PAYLOAD_TEXT); 83 | const invalidatedResult = new SuccessAsync(PAYLOAD_TEXT, true).render( 84 | renders, 85 | ); 86 | expect(invalidatedResult).toBe(INVALIDATED_TEXT + PAYLOAD_TEXT); 87 | }); 88 | 89 | it('returns `success` render result that provides properly typed `payload` given a `Async` in success state', () => { 90 | const PAYLOAD: TestPayload = { data: 'nise0yx6' }; 91 | const renders = { 92 | success: (payload: TestPayload) => payload.data, 93 | }; 94 | const successResult = (new SuccessAsync(PAYLOAD) as Async< 95 | TestPayload 96 | >).render(renders); 97 | expect(successResult).toBe(PAYLOAD.data); 98 | }); 99 | 100 | it('returns `null` if no `success` render provided given a `SuccessAsync`', () => { 101 | const result = new SuccessAsync(undefined).render({}); 102 | expect(result).toBeNull(); 103 | }); 104 | 105 | it('returns `error` render result given an `ErrorAsync`', () => { 106 | const ERROR = new Error('tanfozem'); 107 | const result = new ErrorAsync(ERROR).render({ error: error => error }); 108 | expect(result).toBe(ERROR); 109 | }); 110 | 111 | it('returns `null` if no `error` render provided given an `ErrorAsync`', () => { 112 | const result = new ErrorAsync(new Error()).render({}); 113 | expect(result).toBeNull(); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/Asyncs.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export enum Progress { 4 | Init, 5 | InProgress, 6 | Success, 7 | Error, 8 | } 9 | 10 | class BaseAsnyc { 11 | protected progress: unknown; 12 | public isInit(): this is InitAsync { 13 | return this.progress === Progress.Init; 14 | } 15 | public isInProgress(): this is InProgressAsync { 16 | return this.progress === Progress.InProgress; 17 | } 18 | public isSuccess(): this is SuccessAsync { 19 | return this.progress === Progress.Success; 20 | } 21 | public isError(): this is ErrorAsync { 22 | return this.progress === Progress.Error; 23 | } 24 | public isInProgressOrInvalidated(): this is 25 | | InProgressAsync 26 | | SuccessAsync { 27 | return this.isInProgress() || (this.isSuccess() && !!this.invalidated); 28 | } 29 | public isAborted(): this is InitAsync { 30 | return this.isInit() && !!this.aborted; 31 | } 32 | 33 | public getPayload(): Payload | undefined { 34 | return this.isSuccess() ? this.payload : undefined; 35 | } 36 | 37 | public getError(): Error | undefined { 38 | return this.isError() ? this.error : undefined; 39 | } 40 | 41 | public render({ 42 | init, 43 | inProgress, 44 | success, 45 | error, 46 | }: { 47 | init?: (aborted?: boolean) => ReactNode; 48 | inProgress?: () => ReactNode; 49 | success?: (payload: Payload, invalidated?: boolean) => ReactNode; 50 | error?: (error: Error) => ReactNode; 51 | }): ReactNode { 52 | switch (this.progress) { 53 | case Progress.Init: 54 | return this.isInit() && init ? init(this.aborted) : null; 55 | case Progress.InProgress: 56 | return inProgress ? inProgress() : null; 57 | case Progress.Success: 58 | return this.isSuccess() && success 59 | ? success(this.payload, this.invalidated) 60 | : null; 61 | case Progress.Error: 62 | return this.isError() && error ? error(this.error) : null; 63 | } 64 | } 65 | } 66 | 67 | export class InitAsync extends BaseAsnyc { 68 | public progress: Progress.Init; 69 | public aborted?: boolean; 70 | 71 | public constructor(aborted?: boolean) { 72 | super(); 73 | this.progress = Progress.Init; 74 | this.aborted = aborted; 75 | } 76 | } 77 | 78 | export class InProgressAsync extends BaseAsnyc { 79 | public progress: Progress.InProgress; 80 | 81 | public constructor() { 82 | super(); 83 | this.progress = Progress.InProgress; 84 | } 85 | } 86 | 87 | export class SuccessAsync extends BaseAsnyc { 88 | public progress: Progress.Success; 89 | public payload: Payload; 90 | public invalidated?: boolean; 91 | 92 | public constructor(payload: Payload, invalidated?: boolean) { 93 | super(); 94 | this.progress = Progress.Success; 95 | this.payload = payload; 96 | this.invalidated = invalidated; 97 | } 98 | } 99 | 100 | export class ErrorAsync extends BaseAsnyc { 101 | public progress: Progress.Error; 102 | public error: Error; 103 | 104 | public constructor(error: Error) { 105 | super(); 106 | this.progress = Progress.Error; 107 | this.error = error; 108 | } 109 | } 110 | 111 | export type Async = 112 | | InitAsync 113 | | InProgressAsync 114 | | SuccessAsync 115 | | ErrorAsync; 116 | -------------------------------------------------------------------------------- /src/components/AsyncViewContainer.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, cleanup } from '@testing-library/react'; 3 | import { 4 | InitAsync, 5 | InProgressAsync, 6 | ErrorAsync, 7 | AsyncViewContainer, 8 | } from '../index'; 9 | 10 | afterEach(cleanup); 11 | 12 | it('renders only children given an `InitAsync`', () => { 13 | const CHILDREN_TEXT = 'dargorav'; 14 | const IN_PROGRESS_TEXT = 'ufedussu'; 15 | const ERROR_TEXT = 'povahaer'; 16 | const { container } = render( 17 |

{IN_PROGRESS_TEXT}

} 20 | errorRender={() =>

{ERROR_TEXT}

} 21 | > 22 |

{CHILDREN_TEXT}

23 |
, 24 | ); 25 | expect(container).toHaveTextContent(CHILDREN_TEXT); 26 | expect(container).not.toHaveTextContent(IN_PROGRESS_TEXT); 27 | expect(container).not.toHaveTextContent(IN_PROGRESS_TEXT); 28 | }); 29 | 30 | it('renders only children and `inProgressRender` after it given an `InProgressAsync`', () => { 31 | const CHILDREN_TEXT = 'nopedudo'; 32 | const IN_PROGRESS_TEXT = 'jugaprom'; 33 | const ERROR_TEXT = 'zebozcur'; 34 | const { container } = render( 35 |

{IN_PROGRESS_TEXT}

} 38 | errorRender={() =>

{ERROR_TEXT}

} 39 | > 40 |

{CHILDREN_TEXT}

41 |
, 42 | ); 43 | expect(container).toHaveTextContent( 44 | new RegExp(`${CHILDREN_TEXT}.*${IN_PROGRESS_TEXT}`), 45 | ); 46 | expect(container).not.toHaveTextContent( 47 | new RegExp(`${IN_PROGRESS_TEXT}.*${CHILDREN_TEXT}`), 48 | ); 49 | expect(container).not.toHaveTextContent(ERROR_TEXT); 50 | }); 51 | 52 | it('renders only children and `inProgressRender` before it given an `InProgressAsync` and `setInProgressRenderBeforeChildren`', () => { 53 | const CHILDREN_TEXT = 'agjotawt'; 54 | const IN_PROGRESS_TEXT = 'dokfesje'; 55 | const ERROR_TEXT = 'obhiweng'; 56 | const { container } = render( 57 |

{IN_PROGRESS_TEXT}

} 60 | setInProgressRenderBeforeChildren 61 | errorRender={() =>

{ERROR_TEXT}

} 62 | > 63 |

{CHILDREN_TEXT}

64 |
, 65 | ); 66 | expect(container).toHaveTextContent( 67 | new RegExp(`${IN_PROGRESS_TEXT}.*${CHILDREN_TEXT}`), 68 | ); 69 | expect(container).not.toHaveTextContent( 70 | new RegExp(`${CHILDREN_TEXT}.*${IN_PROGRESS_TEXT}`), 71 | ); 72 | expect(container).not.toHaveTextContent(ERROR_TEXT); 73 | }); 74 | 75 | it('renders only children and `errorRender` after it given an `ErrorAsync`', () => { 76 | const CHILDREN_TEXT = 'dehujije'; 77 | const IN_PROGRESS_TEXT = 'vasaefmi'; 78 | const ERROR_TEXT = 'vibelgas'; 79 | const { container } = render( 80 |

{IN_PROGRESS_TEXT}

} 83 | errorRender={() =>

{ERROR_TEXT}

} 84 | > 85 |

{CHILDREN_TEXT}

86 |
, 87 | ); 88 | expect(container).toHaveTextContent( 89 | new RegExp(`${CHILDREN_TEXT}.*${ERROR_TEXT}`), 90 | ); 91 | expect(container).not.toHaveTextContent( 92 | new RegExp(`${ERROR_TEXT}.*${CHILDREN_TEXT}`), 93 | ); 94 | expect(container).not.toHaveTextContent(IN_PROGRESS_TEXT); 95 | }); 96 | 97 | it('renders only children and `errorRender` before it given an `ErrorAsync` and `setErrorRenderBeforeChildren`', () => { 98 | const CHILDREN_TEXT = 'ifipkodm'; 99 | const IN_PROGRESS_TEXT = 'galweham'; 100 | const ERROR_TEXT = 'gukokubi'; 101 | const { container } = render( 102 |

{IN_PROGRESS_TEXT}

} 105 | errorRender={() =>

{ERROR_TEXT}

} 106 | setErrorRenderBeforeChildren 107 | > 108 |

{CHILDREN_TEXT}

109 |
, 110 | ); 111 | expect(container).toHaveTextContent( 112 | new RegExp(`${ERROR_TEXT}.*${CHILDREN_TEXT}`), 113 | ); 114 | expect(container).not.toHaveTextContent( 115 | new RegExp(`${CHILDREN_TEXT}.*${ERROR_TEXT}`), 116 | ); 117 | expect(container).not.toHaveTextContent(IN_PROGRESS_TEXT); 118 | }); 119 | 120 | it('renders only children and `inProgressRender` after it given an array with any `InProgressAsync` and no `ErrorAsync`', () => { 121 | const CHILDREN_TEXT = 'kegvevlu'; 122 | const IN_PROGRESS_TEXT = 'kafafdeh'; 123 | const ERROR_TEXT = 'favseufp'; 124 | const { container } = render( 125 |

{IN_PROGRESS_TEXT}

} 128 | errorRender={() =>

{ERROR_TEXT}

} 129 | > 130 |

{CHILDREN_TEXT}

131 |
, 132 | ); 133 | expect(container).toHaveTextContent( 134 | new RegExp(`${CHILDREN_TEXT}.*${IN_PROGRESS_TEXT}`), 135 | ); 136 | expect(container).not.toHaveTextContent( 137 | new RegExp(`${IN_PROGRESS_TEXT}.*${CHILDREN_TEXT}`), 138 | ); 139 | expect(container).not.toHaveTextContent(ERROR_TEXT); 140 | }); 141 | 142 | it('renders only children and `errorRender` after it given an array with any `ErrorAsync` and no `InProgressAsync`', () => { 143 | const CHILDREN_TEXT = 'avejarit'; 144 | const IN_PROGRESS_TEXT = 'tivekzio'; 145 | const ERROR_TEXT = 'juzpecto'; 146 | const { container } = render( 147 |

{IN_PROGRESS_TEXT}

} 150 | errorRender={() =>

{ERROR_TEXT}

} 151 | > 152 |

{CHILDREN_TEXT}

153 |
, 154 | ); 155 | expect(container).toHaveTextContent( 156 | new RegExp(`${CHILDREN_TEXT}.*${ERROR_TEXT}`), 157 | ); 158 | expect(container).not.toHaveTextContent( 159 | new RegExp(`${ERROR_TEXT}.*${CHILDREN_TEXT}`), 160 | ); 161 | expect(container).not.toHaveTextContent(IN_PROGRESS_TEXT); 162 | }); 163 | 164 | it('renders both `inProgressRender` and `errorRender`given an array with a `InProgressAsync` and a `ErrorAsync`', () => { 165 | const IN_PROGRESS_TEXT = 'tivekzio'; 166 | const ERROR_TEXT = 'juzpecto'; 167 | const { container } = render( 168 |

{IN_PROGRESS_TEXT}

} 171 | errorRender={() =>

{ERROR_TEXT}

} 172 | > 173 |

zubihora

174 |
, 175 | ); 176 | expect(container).toHaveTextContent(IN_PROGRESS_TEXT); 177 | expect(container).toHaveTextContent(ERROR_TEXT); 178 | }); 179 | 180 | it('renders one `errorRender` per `ErrorAsync` in the given an array', () => { 181 | const ERROR_1_TEXT = 'cenafret'; 182 | const ERROR_2_TEXT = 'calkoagu'; 183 | const ERROR_3_TEXT = 'nihuugep'; 184 | const { container } = render( 185 | 193 | errors.map((error, i) =>

{error.message}

) 194 | } 195 | > 196 |

gubupgen

197 |
, 198 | ); 199 | expect(container).toHaveTextContent( 200 | new RegExp(`${ERROR_1_TEXT}.*${ERROR_2_TEXT}.*${ERROR_3_TEXT}`), 201 | ); 202 | }); 203 | 204 | it('renders `inProgressRender` given no `InProgressAsync` but `forceInProgress`', () => { 205 | const IN_PROGRESS_TEXT = 'rofmomeb'; 206 | const { container } = render( 207 |

{IN_PROGRESS_TEXT}

} 210 | forceInProgress 211 | errorRender={null} 212 | > 213 |

ozepejhu

214 |
, 215 | ); 216 | expect(container).toHaveTextContent(IN_PROGRESS_TEXT); 217 | }); 218 | 219 | it('renders `errorRender` given no `ErrorAsync` but `forceError`', () => { 220 | const ERROR_TEXT = 'nafbuvim'; 221 | const { container } = render( 222 |

{ERROR_TEXT}

} 226 | forceError={new Error()} 227 | > 228 |

lihasaze

229 |
, 230 | ); 231 | expect(container).toHaveTextContent(ERROR_TEXT); 232 | }); 233 | -------------------------------------------------------------------------------- /src/components/AsyncViewContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, ReactElement } from 'react'; 2 | import { isAnyInProgressOrInvalidated } from '../helpers'; 3 | import { Async, ErrorAsync } from '../Asyncs'; 4 | 5 | interface PropsSingle { 6 | asyncData: Async; 7 | inProgressRender: (() => ReactNode) | null; 8 | setInProgressRenderBeforeChildren?: boolean; 9 | forceInProgress?: boolean; 10 | errorRender: ((error: Error) => ReactNode) | null; 11 | setErrorRenderBeforeChildren?: boolean; 12 | forceError?: Error; 13 | children: ReactNode; 14 | } 15 | 16 | interface PropsMulti { 17 | asyncData: Async[]; 18 | inProgressRender: (() => ReactNode) | null; 19 | setInProgressRenderBeforeChildren?: boolean; 20 | forceInProgress?: boolean; 21 | errorRender: ((errors: Error[]) => ReactNode) | null; 22 | setErrorRenderBeforeChildren?: boolean; 23 | forceError?: Error[]; 24 | children: ReactNode; 25 | } 26 | 27 | type Props = PropsSingle | PropsMulti; 28 | 29 | export function AsyncViewContainer({ 30 | asyncData, 31 | inProgressRender, 32 | setInProgressRenderBeforeChildren = false, 33 | forceInProgress, 34 | errorRender, 35 | setErrorRenderBeforeChildren = false, 36 | forceError, 37 | children, 38 | }: Props): ReactElement { 39 | const isInProgress = 40 | forceInProgress || 41 | (Array.isArray(asyncData) 42 | ? isAnyInProgressOrInvalidated(...asyncData) 43 | : asyncData.isInProgressOrInvalidated()); 44 | const errors = 45 | forceError || 46 | (Array.isArray(asyncData) 47 | ? asyncData 48 | .filter( 49 | (singleAsyncData): singleAsyncData is ErrorAsync => 50 | singleAsyncData.isError(), 51 | ) 52 | .map(singleAsyncData => singleAsyncData.error) 53 | : asyncData.getError()); 54 | return ( 55 | <> 56 | {errors && 57 | (!Array.isArray(errors) || errors.length > 0) && 58 | errorRender && 59 | setErrorRenderBeforeChildren 60 | ? (errorRender as (error: Error | Error[]) => ReactNode)(errors) 61 | : null} 62 | 63 | {isInProgress && inProgressRender && setInProgressRenderBeforeChildren 64 | ? inProgressRender() 65 | : null} 66 | 67 | {children} 68 | 69 | {isInProgress && inProgressRender && !setInProgressRenderBeforeChildren 70 | ? inProgressRender() 71 | : null} 72 | 73 | {errors && 74 | (!Array.isArray(errors) || errors.length > 0) && 75 | errorRender && 76 | !setErrorRenderBeforeChildren 77 | ? (errorRender as (error: Error | Error[]) => ReactNode)(errors) 78 | : null} 79 | 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /src/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { Async, SuccessAsync, ErrorAsync, map } from './index'; 2 | 3 | describe('map', () => { 4 | it('maps `SuccessAsync`s', () => { 5 | const SOME_NUMBER = 49; 6 | expect( 7 | map(new SuccessAsync({ x: SOME_NUMBER }), payload => payload.x), 8 | ).toEqual(new SuccessAsync(SOME_NUMBER)); 9 | }); 10 | 11 | it('does not map `ErrorAsync`s', () => { 12 | const SOME_ERROR_ASYNC = new ErrorAsync(new Error('Oh shit')) as Async<{ 13 | x: number; 14 | }>; 15 | expect(map(SOME_ERROR_ASYNC, payload => payload.x)).toEqual( 16 | SOME_ERROR_ASYNC, 17 | ); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Async, 3 | ErrorAsync, 4 | InitAsync, 5 | InProgressAsync, 6 | SuccessAsync, 7 | } from './Asyncs'; 8 | 9 | // 10 | // Async Data state checkers 11 | // 12 | 13 | export const isAnyInit = (...args: Async[]): boolean => 14 | args.some(asyncData => asyncData.isInit()); 15 | export const isAnyInProgress = (...args: Async[]): boolean => 16 | args.some(asyncData => asyncData.isInProgress()); 17 | export const isAnySuccess = (...args: Async[]): boolean => 18 | args.some(asyncData => asyncData.isSuccess()); 19 | export const isAnyError = (...args: Async[]): boolean => 20 | args.some(asyncData => asyncData.isError()); 21 | 22 | export const isAnyInProgressOrInvalidated = ( 23 | ...args: Async[] 24 | ): boolean => args.some(asyncData => asyncData.isInProgressOrInvalidated()); 25 | export const isAnyaborted = (...args: Async[]): boolean => 26 | args.some(asyncData => asyncData.isAborted()); 27 | 28 | // 29 | // Async Data transformations 30 | // 31 | 32 | export const setInProgressOrInvalidated = ( 33 | origin: Async, 34 | ): InProgressAsync | SuccessAsync => 35 | origin.isSuccess() 36 | ? new SuccessAsync(origin.payload, true) 37 | : new InProgressAsync(); 38 | 39 | export const setInitOrAborted = ( 40 | origin: Async, 41 | ): InitAsync => new InitAsync(origin.isInProgressOrInvalidated()); 42 | 43 | export const map = ( 44 | origin: Async, 45 | mapper: (payload: Payload1) => Payload2, 46 | invalidated?: boolean, 47 | ): Async => 48 | origin.isSuccess() 49 | ? new SuccessAsync( 50 | mapper(origin.payload), 51 | invalidated !== undefined ? invalidated : origin.invalidated, 52 | ) 53 | : ((origin as unknown) as Async); 54 | 55 | // 56 | // Higher level helpers 57 | // 58 | 59 | export interface AsyncTaskOptions { 60 | onSuccess?: ((payload: Payload) => void) | undefined; 61 | onError?: ((error: Error) => void) | undefined; 62 | } 63 | 64 | export async function triggerTask( 65 | task: () => Promise, 66 | callback: ( 67 | setNewAsyncData: (prevAsyncData?: Async) => Async, 68 | ) => boolean | void, 69 | { onSuccess, onError }: AsyncTaskOptions = {}, 70 | ): Promise> { 71 | callback(prevAsyncData => 72 | prevAsyncData 73 | ? setInProgressOrInvalidated(prevAsyncData) 74 | : new InProgressAsync(), 75 | ); 76 | try { 77 | const result = await task(); 78 | const successAsync = new SuccessAsync(result); 79 | const cancalUpdates = callback(() => successAsync); 80 | !cancalUpdates && onSuccess && onSuccess(result); 81 | return successAsync; 82 | } catch (error) { 83 | if (error && error.name === 'AbortError') { 84 | const abortedAsync = new InitAsync(true); 85 | callback(() => abortedAsync); 86 | return abortedAsync; 87 | } else { 88 | const errorAsync = new ErrorAsync(error); 89 | const cancalUpdates = callback(() => errorAsync); 90 | !cancalUpdates && onError && onError(error); 91 | return errorAsync; 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/hooks/useAsyncData.test.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, fireEvent, render, wait } from '@testing-library/react'; 2 | import React, { ReactNode, ReactElement } from 'react'; 3 | import { CALLS_LIMIT } from './useStopRunawayEffect'; 4 | import { Async, AsyncData, useAsyncData, UseAsyncDataOptions } from '../index'; 5 | 6 | afterEach(cleanup); 7 | 8 | interface Props { 9 | getData: (singal?: AbortSignal) => Promise; 10 | options?: UseAsyncDataOptions; 11 | children: (asyncData: AsyncData) => ReactNode; 12 | } 13 | 14 | function UseAsyncDataComponent({ 15 | getData, 16 | options, 17 | children, 18 | }: Props): ReactElement { 19 | return <>{children(useAsyncData(getData, options))}; 20 | } 21 | 22 | function getAbortablePromise({ 23 | resolveWith, 24 | rejectWith, 25 | signal, 26 | onAbortCallback, 27 | }: { 28 | resolveWith?: Payload; 29 | rejectWith?: Error; 30 | signal: AbortSignal | undefined; 31 | onAbortCallback: () => void; 32 | }): Promise { 33 | if (signal && signal.aborted) { 34 | return Promise.reject(new DOMException('Aborted', 'AbortError')); 35 | } 36 | return new Promise((resolve, reject) => { 37 | signal && 38 | signal.addEventListener('abort', () => { 39 | onAbortCallback && onAbortCallback(); 40 | reject(new DOMException('Aborted', 'AbortError')); 41 | }); 42 | resolveWith !== undefined && resolve(resolveWith); 43 | rejectWith !== undefined && reject(rejectWith); 44 | }); 45 | } 46 | 47 | it('updates async data up to `SuccessAsync` state and invokes `onSuccess` callback', async () => { 48 | const IN_PROGRESS_TEXT = 'IN_PROGRESS_bonihzes'; 49 | const SUCCESS_TEXT = 'SUCCESS_ukejemuo'; 50 | const PAYLOAD = 'PAYLOAD_ezhihnoi'; 51 | const onSuccessCallback = jest.fn(); 52 | const { container } = render( 53 | Promise.resolve(PAYLOAD)} 55 | options={{ onSuccess: onSuccessCallback }} 56 | > 57 | {asyncData => 58 | asyncData.render({ 59 | inProgress: () => IN_PROGRESS_TEXT, 60 | success: () => SUCCESS_TEXT, 61 | }) 62 | } 63 | , 64 | ); 65 | expect(container).toHaveTextContent(IN_PROGRESS_TEXT); 66 | expect(onSuccessCallback).toHaveBeenCalledTimes(0); 67 | await wait(); 68 | expect(container).toHaveTextContent(SUCCESS_TEXT); 69 | expect(onSuccessCallback).toHaveBeenCalledTimes(1); 70 | expect(onSuccessCallback).toHaveBeenCalledWith(PAYLOAD); 71 | }); 72 | 73 | it('updates async data up to `ErrorAsync` state and invokes `onError` callback', async () => { 74 | const IN_PROGRESS_TEXT = 'IN_PROGRESS_tokunalf'; 75 | const ERROR_TEXT = 'ERROR_vaowdenz'; 76 | const ERROR = new Error(); 77 | const onErrorCallback = jest.fn(); 78 | const { container } = render( 79 | Promise.reject(ERROR)} 81 | options={{ onError: onErrorCallback }} 82 | > 83 | {asyncData => 84 | asyncData.render({ 85 | inProgress: () => IN_PROGRESS_TEXT, 86 | error: () => ERROR_TEXT, 87 | }) 88 | } 89 | , 90 | ); 91 | expect(container).toHaveTextContent(IN_PROGRESS_TEXT); 92 | expect(onErrorCallback).toHaveBeenCalledTimes(0); 93 | await wait(); 94 | expect(container).toHaveTextContent(ERROR_TEXT); 95 | expect(onErrorCallback).toHaveBeenCalledTimes(1); 96 | expect(onErrorCallback).toHaveBeenCalledWith(ERROR); 97 | }); 98 | 99 | it('does not update async data state if disabled', async () => { 100 | const REFRESH_BUTTON_TEST_ID = 'gohsuzog'; 101 | const INIT_TEXT = 'INIT_uddokbof'; 102 | const { container, getByTestId } = render( 103 | Promise.resolve()} 105 | options={{ disabled: true }} 106 | > 107 | {asyncData => ( 108 | <> 109 | {asyncData.render({ init: () => INIT_TEXT })} 110 |