├── .circleci └── config.yml ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── CODE_OF_CONDUCT.md ├── Contributing.md ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── scripts └── prepare-deploy-package-json.js ├── src ├── index.ts ├── use-async-effect.spec.tsx └── use-async-effect.ts ├── tsconfig.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | 7 | references: 8 | js_deps_cache_key: &js_deps_cache_key v8-dependency-js-deps-{{ checksum "yarn.lock" }} 9 | js_deps_backup_cache_key: &js_deps_backup_cache_key v8-dependency-js-deps 10 | 11 | jobs: 12 | build-job: 13 | docker: 14 | - image: circleci/node:12 15 | working_directory: /tmp/workspace 16 | steps: 17 | - checkout 18 | - restore_cache: 19 | keys: 20 | - *js_deps_cache_key 21 | - *js_deps_backup_cache_key 22 | - run: yarn install 23 | - save_cache: 24 | key: *js_deps_cache_key 25 | paths: 26 | - node_modules 27 | - run: yarn test 28 | - run: yarn lint 29 | - run: yarn build 30 | 31 | deploy-job: 32 | docker: 33 | - image: circleci/node:12 34 | working_directory: /tmp/workspace 35 | steps: 36 | - checkout 37 | - restore_cache: 38 | keys: 39 | - *js_deps_cache_key 40 | - *js_deps_backup_cache_key 41 | - run: yarn build 42 | - run: node scripts/prepare-deploy-package-json.js 43 | - run: yarn semantic-release 44 | 45 | workflows: 46 | version: 2 47 | build-deploy: 48 | jobs: 49 | - build-job 50 | - deploy-job: 51 | requires: 52 | - build-job 53 | filters: 54 | branches: 55 | only: master 56 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | parser: "@typescript-eslint/parser", 5 | extends: [ 6 | "plugin:react/recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "prettier", 9 | "prettier/@typescript-eslint" 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 2018, 13 | sourceType: "module" 14 | }, 15 | rules: { 16 | "@typescript-eslint/explicit-function-return-type": ["off"] 17 | }, 18 | settings: { 19 | react: { 20 | version: "detect" 21 | } 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Issue tracker is **ONLY** used for reporting bugs. Please use [stackoverflow](https://stackoverflow.com) for supporting issues. 2 | 3 | 4 | 5 | ## Expected Behavior 6 | 7 | 8 | 9 | ## Current Behavior 10 | 11 | 12 | 13 | ## Possible Solution 14 | 15 | 16 | 17 | ## Steps to Reproduce 18 | 19 | 20 | 21 | 22 | 1. 2. 3. 4. 23 | 24 | 25 | 26 | Codesandbox Link: 27 | 28 | ## Context (Environment) 29 | 30 | 31 | 32 | 33 | 34 | 35 | ## Detailed Description 36 | 37 | 38 | 39 | ## Possible Implementation 40 | 41 | 42 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | THIS PROJECT IS IN MAINTENANCE MODE. We accept pull-requests for Bug Fixes **ONLY**. NO NEW FEATURES ACCEPTED! 2 | 3 | 4 | 5 | ## Description 6 | 7 | 8 | 9 | ## Related Issue 10 | 11 | 12 | 13 | 14 | 15 | 16 | ## Motivation and Context 17 | 18 | 19 | 20 | 21 | ## How Has This Been Tested? 22 | 23 | 24 | 25 | 26 | 27 | ## I created a failing test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | *.log 4 | dist 5 | build 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "eslint.validate": [ 4 | "javascript", 5 | "javascriptreact", 6 | "typescript", 7 | "typescriptreact" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at laurinquast@googlemail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /Contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for being willing to contribute! 4 | 5 | Before setting up you project consider the following: 6 | 7 | - Please do not send any feature request/pull-request before discussing them in issues. 8 | - Please cover every bug fix with a test. 9 | - Please fill out the issues/pull-request templates. 10 | 11 | ## Project setup 12 | 13 | 1. Fork and clone the repo 14 | 2. Run `yarn install` to install dependencies 15 | 3. Create a branch for your PR with `git checkout -b pr/your-branch-name` 16 | 17 | > Tip: Keep your `master` branch pointing at the original repository and make 18 | > pull requests from branches on your fork. To do this, run: 19 | > 20 | > ``` 21 | > git remote add upstream https://github.com/n1ru4l/use-async-effect.git 22 | > git fetch upstream 23 | > git branch --set-upstream-to=upstream/master master 24 | > ``` 25 | > 26 | > This will add the original repository as a "remote" called "upstream," Then 27 | > fetch the git information from that remote, then set your local `master` 28 | > branch to use the upstream master branch whenever you run `git pull`. Then you 29 | > can make all of your pull request branches based on this `master` branch. 30 | > Whenever you want to update your version of `master`, do a regular `git pull`. 31 | 32 | ## Committing and Pushing changes 33 | 34 | Please make sure to run the tests before you commit your changes. You can run them with 35 | `yarn test`. 36 | 37 | There is a pre-commit hook that will run the tests and format the files before commiting. 38 | 39 | In case you want to skip the test run, e.g. by first pushing a failing tests, you can skip the hook with `git commit --no-verify`. 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-present Laurin Quast 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 | # useAsyncEffect 2 | 3 | [![npm](https://img.shields.io/npm/v/@n1ru4l/use-async-effect.svg)](https://www.npmjs.com/package/@n1ru4l/use-async-effect) 4 | [![npm bundle size](https://img.shields.io/bundlephobia/min/@n1ru4l/use-async-effect)](https://bundlephobia.com/result?p=@n1ru4l/use-async-effect) 5 | [![Dependencies](https://img.shields.io/david/n1ru4l/use-async-effect)](https://www.npmjs.com/package/@n1ru4l/use-async-effect) 6 | [![NPM](https://img.shields.io/npm/dm/@n1ru4l/use-async-effect.svg)](https://www.npmjs.com/package/@n1ru4l/use-async-effect) 7 | [![CircleCI](https://img.shields.io/circleci/build/github/n1ru4l/use-async-effect.svg)](https://circleci.com/gh/n1ru4l/use-async-effect) 8 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 9 | 10 | Simple type-safe async effects for React powered by [generator functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*). 11 | 12 | ```tsx 13 | import React from "react"; 14 | import useAsyncEffect from "@n1ru4l/use-async-effect"; 15 | 16 | const MyComponent = ({ filter }) => { 17 | const [data, setData] = React.useState(null); 18 | 19 | useAsyncEffect( 20 | function* (onCancel, c) { 21 | const controller = new AbortController(); 22 | 23 | onCancel(() => controller.abort()); 24 | 25 | const data = yield* c( 26 | fetch("/data?filter=" + filter, { 27 | signal: controller.signal, 28 | }).then((res) => res.json()) 29 | ); 30 | 31 | setData(data); 32 | }, 33 | [filter] 34 | ); 35 | 36 | return data ? : null; 37 | }; 38 | ``` 39 | 40 | 41 | 42 | 43 | - [Install Instructions](#install-instructions) 44 | - [The problem](#the-problem) 45 | - [Example](#example) 46 | - [Before 😖](#before-) 47 | - [After 🤩](#after-) 48 | - [Usage](#usage) 49 | - [Basic Usage](#basic-usage) 50 | - [Cancel handler (Cancelling an in-flight `fetch` request)](#cancel-handler-cancelling-an-in-flight-fetch-request) 51 | - [Cleanup Handler](#cleanup-handler) 52 | - [Setup eslint for `eslint-plugin-react-hooks`](#setup-eslint-for-eslint-plugin-react-hooks) 53 | - [TypeScript](#typescript) 54 | - [API](#api) 55 | - [`useAsyncEffect` Hook](#useasynceffect-hook) 56 | - [Contributing](#contributing) 57 | - [LICENSE](#license) 58 | 59 | 60 | 61 | ## Install Instructions 62 | 63 | `yarn add -E @n1ru4l/use-async-effect` 64 | 65 | or 66 | 67 | `npm install -E @n1ru4l/use-async-effect` 68 | 69 | ## The problem 70 | 71 | Doing async stuff with `useEffect` clutters your code: 72 | 73 | - 😖 You cannot pass an async function to `useEffect` 74 | - 🤢 You cannot cancel an async function 75 | - 🤮 You have to manually keep track whether you can set state or not 76 | 77 | This micro library tries to solve this issue by using generator functions: 78 | 79 | - ✅ Pass a generator to `useAsyncEffect` 80 | - ✅ Return cleanup function from generator function 81 | - ✅ Automatically stop running the generator after the dependency list has changed or the component did unmount 82 | - ✅ Optional cancelation handling via events e.g. for canceling your `fetch` request with [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController#Examples) 83 | 84 | ## Example 85 | 86 | ### Before 😖 87 | 88 | ```jsx 89 | import React, { useEffect } from "react"; 90 | 91 | const MyComponent = ({ filter }) => { 92 | const [data, setData] = useState(null); 93 | 94 | useEffect(() => { 95 | let isCanceled = false; 96 | const controller = new AbortController(); 97 | 98 | const runHandler = async () => { 99 | try { 100 | const data = await fetch("/data?filter=" + filter, { 101 | signal: controller.signal, 102 | }).then((res) => res.json()); 103 | if (isCanceled) { 104 | return; 105 | } 106 | setData(data); 107 | } catch (err) {} 108 | }; 109 | 110 | runHandler(); 111 | return () => { 112 | isCanceled = true; 113 | controller.abort(); 114 | }; 115 | }, [filter]); 116 | 117 | return data ? : null; 118 | }; 119 | ``` 120 | 121 | ### After 🤩 122 | 123 | ```jsx 124 | import React from "react"; 125 | import useAsyncEffect from "@n1ru4l/use-async-effect"; 126 | 127 | const MyComponent = ({ filter }) => { 128 | const [data, setData] = useState(null); 129 | 130 | useAsyncEffect( 131 | function* (onCancel, c) { 132 | const controller = new AbortController(); 133 | 134 | onCancel(() => controller.abort()); 135 | 136 | const data = yield* c( 137 | fetch("/data?filter=" + filter, { 138 | signal: controller.signal, 139 | }).then((res) => res.json()) 140 | ); 141 | 142 | setData(data); 143 | }, 144 | [filter] 145 | ); 146 | 147 | return data ? : null; 148 | }; 149 | ``` 150 | 151 | ## Usage 152 | 153 | Works like `useEffect`, but with a generator function. 154 | 155 | ### Basic Usage 156 | 157 | ```jsx 158 | import React, { useState } from "react"; 159 | import useAsyncEffect from "@n1ru4l/use-async-effect"; 160 | 161 | const MyDoggoImage = () => { 162 | const [doggoImageSrc, setDoggoImageSrc] = useState(null); 163 | useAsyncEffect(function* (_, c) { 164 | const { message } = yield* c( 165 | fetch("https://dog.ceo/api/breeds/image/random").then((res) => res.json()) 166 | ); 167 | setDoggoImageSrc(message); 168 | }, []); 169 | 170 | return doggoImageSrc ? : null; 171 | }; 172 | ``` 173 | 174 | [![Edit use-async-effect doggo demo](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/use-async-effect-doggo-demo-qrqix?fontsize=14) 175 | 176 | ### Cancel handler (Cancelling an in-flight `fetch` request) 177 | 178 | You can react to cancels, that might occur while a promise has not resolved yet, by registering a handler via `onCancel`. 179 | After an async operation has been processed, the `onCancel` handler is automatically being unset. 180 | 181 | ```jsx 182 | import React, { useState } from "react"; 183 | import useAsyncEffect from "@n1ru4l/use-async-effect"; 184 | 185 | const MyDoggoImage = () => { 186 | const [doggoImageSrc, setDoggoImageSrc] = useState(null); 187 | useAsyncEffect(function* (onCancel, c) { 188 | const abortController = new AbortController(); 189 | onCancel(() => abortController.abort()); 190 | const { message } = yield c( 191 | fetch("https://dog.ceo/api/breeds/image/random", { 192 | signal: abortController.signal, 193 | }).then((res) => res.json()) 194 | ); 195 | setDoggoImageSrc(message); 196 | }, []); 197 | 198 | return doggoImageSrc ? : null; 199 | }; 200 | ``` 201 | 202 | [![Edit use-async-effect doggo cancel demo](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/use-async-effect-doggo-cancel-demo-6rxvd?fontsize=14) 203 | 204 | ### Cleanup Handler 205 | 206 | Similar to `React.useEffect` you can return a cleanup function from your generator function. 207 | It will be called once the effect dependencies change or the component is unmounted. 208 | Please take note that the whole generator must be executed before the cleanup handler can be invoked. 209 | In case you setup event listeners etc. earlier you will also have to clean them up by specifiying a cancel handler. 210 | 211 | ```jsx 212 | import React, { useState } from "react"; 213 | import useAsyncEffect from "@n1ru4l/use-async-effect"; 214 | 215 | const MyDoggoImage = () => { 216 | const [doggoImageSrc, setDoggoImageSrc] = useState(null); 217 | useAsyncEffect(function* (_, c) { 218 | const { message } = yield* c( 219 | fetch("https://dog.ceo/api/breeds/image/random").then((res) => res.json()) 220 | ); 221 | setDoggoImageSrc(message); 222 | 223 | const listener = () => { 224 | console.log("I LOVE DOGGIES", message); 225 | }; 226 | window.addEventListener("mousemove", listener); 227 | return () => window.removeEventListener("mousemove", listener); 228 | }, []); 229 | 230 | return doggoImageSrc ? : null; 231 | }; 232 | ``` 233 | 234 | [![Edit use-async-effect cleanup doggo demo](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/use-async-effect-doggo-demo-w1zlh?fontsize=14) 235 | 236 | ### Setup eslint for `eslint-plugin-react-hooks` 237 | 238 | You need to configure the `react-hooks/exhaustive-deps` plugin to treat `useAsyncEffect` as a hook with dependencies. 239 | 240 | Add the following to your eslint config file: 241 | 242 | ```json 243 | { 244 | "rules": { 245 | "react-hooks/exhaustive-deps": [ 246 | "warn", 247 | { 248 | "additionalHooks": "useAsyncEffect" 249 | } 250 | ] 251 | } 252 | } 253 | ``` 254 | 255 | ### TypeScript 256 | 257 | We expose a helper function for TypeScript that allows interferring the correct Promise resolve type. It uses some type-casting magic under the hood and requires you to use the `yield*` keyword instead of the `yield` keyword. 258 | 259 | ```tsx 260 | useAsyncEffect(function* (setErrorHandler, c) { 261 | const numericValue = yield* c(Promise.resolve(123)); 262 | // type of numericValue is number 🎉 263 | }); 264 | ``` 265 | 266 | ## API 267 | 268 | ### `useAsyncEffect` Hook 269 | 270 | Runs a effect that includes async operations. The effect ins cancelled upon dependency change/unmount. 271 | 272 | ```ts 273 | function useAsyncEffect( 274 | createGenerator: ( 275 | setCancelHandler: ( 276 | onCancel?: null | (() => void), 277 | onCancelError?: null | ((err: Error) => void) 278 | ) => void, 279 | cast: (promise: Promise) => Generator, T> 280 | ) => Iterator, 281 | deps?: React.DependencyList 282 | ): void; 283 | ``` 284 | 285 | ## Contributing 286 | 287 | Please check our contribution guides [Contributing](https://github.com/n1ru4l/use-async-effect/blob/master/Contributing.md). 288 | 289 | ## LICENSE 290 | 291 | [MIT](https://github.com/n1ru4l/use-async-effect/blob/master/LICENSE). 292 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | roots: ["/src"], 5 | transform: { 6 | "^.+\\.tsx?$": "ts-jest" 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@n1ru4l/use-async-effect", 3 | "version": "0.0.0-semantically-released", 4 | "license": "MIT", 5 | "author": { 6 | "name": "Laurin Quast", 7 | "email": "laurinquast@googlemail.com", 8 | "url": "https://github.com/n1ru4l" 9 | }, 10 | "homepage": "https://github.com/n1ru4l/use-async-effect#readme", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/n1ru4l/use-async-effect" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/n1ru4l/use-async-effect/issues" 17 | }, 18 | "keywords": [ 19 | "react", 20 | "async", 21 | "hook" 22 | ], 23 | "module": "dist/module/index.js", 24 | "main": "dist/main/index.js", 25 | "typings": "dist/module/index.d.ts", 26 | "devDependencies": { 27 | "@testing-library/react": "11.2.5", 28 | "@types/jest": "26.0.20", 29 | "@types/react": "17.0.2", 30 | "@typescript-eslint/eslint-plugin": "4.15.1", 31 | "@typescript-eslint/parser": "4.15.1", 32 | "doctoc": "2.0.0", 33 | "eslint": "7.20.0", 34 | "eslint-config-prettier": "7.2.0", 35 | "eslint-plugin-jest": "24.1.5", 36 | "eslint-plugin-react": "7.22.0", 37 | "husky": "5.0.9", 38 | "jest": "26.6.3", 39 | "lint-staged": "10.5.4", 40 | "prettier": "2.2.1", 41 | "react": "17.0.1", 42 | "react-dom": "17.0.1", 43 | "rimraf": "3.0.2", 44 | "semantic-release": "17.3.9", 45 | "ts-jest": "26.5.1", 46 | "typescript": "4.1.5" 47 | }, 48 | "peerDependencies": { 49 | "react": "^16.8.6 || 17.x || 18.x" 50 | }, 51 | "scripts": { 52 | "lint": "eslint --ignore-path .gitignore --ext .ts,.tsx \"src/**/*\"", 53 | "test": "jest", 54 | "build:module": "tsc --target es2017 --outDir dist/module", 55 | "build:main": "tsc --target es5 --outDir dist/main", 56 | "build": "rimraf dist && yarn build:module && yarn build:main" 57 | }, 58 | "files": [ 59 | "dist/**/*", 60 | "LICENSE", 61 | "README.md" 62 | ], 63 | "husky": { 64 | "hooks": { 65 | "pre-commit": "yarn test && lint-staged" 66 | } 67 | }, 68 | "lint-staged": { 69 | "*.{yml,ts,tsx,js,json}": [ 70 | "prettier --write" 71 | ], 72 | "*.{ts,tsx,js}": [ 73 | "eslint" 74 | ], 75 | "*.md": [ 76 | "doctoc", 77 | "prettier --write" 78 | ] 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /scripts/prepare-deploy-package-json.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | "use strict"; 3 | 4 | const fs = require("fs"); 5 | const path = require("path"); 6 | 7 | const pickFields = [ 8 | "name", 9 | "version", 10 | "license", 11 | "author", 12 | "homepage", 13 | "repository", 14 | "bugs", 15 | "keywords", 16 | "module", 17 | "main", 18 | "typings", 19 | "dependencies", 20 | "peerDependencies", 21 | "files", 22 | ]; 23 | 24 | const filePath = path.resolve(__dirname, "..", "package.json"); 25 | const contents = fs.readFileSync(filePath); 26 | const packageJson = JSON.parse(contents); 27 | const newPackageJson = Object.fromEntries( 28 | Object.entries(packageJson).filter(([key]) => pickFields.includes(key)) 29 | ); 30 | fs.writeFileSync(filePath, JSON.stringify(newPackageJson, null, 2)); 31 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { useAsyncEffect } from "./use-async-effect"; 2 | 3 | export default useAsyncEffect; 4 | -------------------------------------------------------------------------------- /src/use-async-effect.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useAsyncEffect } from "./use-async-effect"; 3 | import { cleanup, render, act } from "@testing-library/react"; 4 | 5 | afterEach(cleanup); 6 | 7 | it("can be used", () => { 8 | const TestComponent: React.FC = () => { 9 | // eslint-disable-next-line @typescript-eslint/no-empty-function 10 | useAsyncEffect(function* () {}, []); 11 | return null; 12 | }; 13 | render(); 14 | }); 15 | 16 | it("calls the generator", () => { 17 | const callable = jest.fn(); 18 | const TestComponent: React.FC = () => { 19 | useAsyncEffect(function* () { 20 | callable(); 21 | }, []); 22 | return null; 23 | }; 24 | render(); 25 | expect(callable).toHaveBeenCalledTimes(1); 26 | }); 27 | 28 | it("calls the generator again once a dependency changes", () => { 29 | const callable = jest.fn(); 30 | 31 | let setState = (() => undefined) as (str: string) => void; 32 | 33 | const TestComponent: React.FC = () => { 34 | const [state, _setState] = React.useState("hello"); 35 | useAsyncEffect( 36 | function* () { 37 | callable(); 38 | }, 39 | [state] 40 | ); 41 | setState = _setState; 42 | return null; 43 | }; 44 | 45 | render(); 46 | 47 | act(() => { 48 | setState("bye"); 49 | }); 50 | expect(callable).toHaveBeenCalledTimes(2); 51 | }); 52 | 53 | 54 | it("calls the generator on each render if no dependency list given", () => { 55 | const callable = jest.fn(); 56 | 57 | let setState = (() => undefined) as (str: string) => void; 58 | 59 | const TestComponent: React.FC = () => { 60 | const [, _setState] = React.useState("hello"); 61 | useAsyncEffect(function* () { 62 | callable(); 63 | }); 64 | setState = _setState; 65 | return null; 66 | }; 67 | 68 | render(); 69 | 70 | act(() => { 71 | setState("aye"); 72 | }); 73 | act(() => { 74 | setState("bye"); 75 | }); 76 | 77 | expect(callable).toHaveBeenCalledTimes(3); 78 | }); 79 | 80 | it("yield can resolve a non promise object", () => { 81 | const callable = jest.fn(); 82 | 83 | const TestComponent: React.FC = () => { 84 | useAsyncEffect(function* () { 85 | const value: string = yield "foobars"; 86 | callable(value); 87 | }, []); 88 | return null; 89 | }; 90 | 91 | render(); 92 | 93 | expect(callable).toHaveBeenCalledWith("foobars"); 94 | }); 95 | 96 | it("yield can resolve a promise object", async () => { 97 | const callable = jest.fn(); 98 | 99 | const TestComponent: React.FC = () => { 100 | useAsyncEffect(function* () { 101 | const value: string = yield Promise.resolve("foobars"); 102 | callable(value); 103 | }, []); 104 | return null; 105 | }; 106 | 107 | render(); 108 | 109 | // wait until next tick 110 | await Promise.resolve(); 111 | 112 | expect(callable).toHaveBeenCalledWith("foobars"); 113 | }); 114 | 115 | it("effect can be canceled", async () => { 116 | const callable = jest.fn(); 117 | 118 | const TestComponent: React.FC = () => { 119 | useAsyncEffect(function* () { 120 | const value: string = yield Promise.resolve("foobars"); 121 | callable(value); 122 | }, []); 123 | return null; 124 | }; 125 | 126 | const { unmount } = render(); 127 | 128 | // unmount to initiate the cancel 129 | unmount(); 130 | 131 | // wait until next tick 132 | await Promise.resolve(); 133 | 134 | // the promise is resolved but the generator call-loop is suspended 135 | // therefore callable should never be called 136 | expect(callable).toHaveBeenCalledTimes(0); 137 | }); 138 | 139 | it("calls a handler for canceling", async () => { 140 | const callable = jest.fn(); 141 | 142 | const TestComponent: React.FC = () => { 143 | useAsyncEffect(function* (onCancel) { 144 | onCancel(() => { 145 | callable("cancel"); 146 | }); 147 | yield Promise.resolve("foobars"); 148 | }, []); 149 | return null; 150 | }; 151 | 152 | const { unmount } = render(); 153 | unmount(); 154 | 155 | // wait until next tick 156 | await Promise.resolve(); 157 | 158 | expect(callable).toHaveBeenCalledTimes(1); 159 | expect(callable).toHaveBeenCalledWith("cancel"); 160 | }); 161 | 162 | it("does not call a undefined handler", async () => { 163 | const callable = jest.fn(); 164 | 165 | const TestComponent: React.FC = () => { 166 | useAsyncEffect(function* (onCancel) { 167 | onCancel(() => { 168 | callable("cancel"); 169 | }); 170 | onCancel(); 171 | yield Promise.resolve("foobars"); 172 | }, []); 173 | return null; 174 | }; 175 | 176 | const { unmount } = render(); 177 | unmount(); 178 | 179 | // wait until next tick 180 | await Promise.resolve(); 181 | 182 | expect(callable).toHaveBeenCalledTimes(0); 183 | }); 184 | 185 | it("does override cancel handler", async () => { 186 | const callable = jest.fn(); 187 | 188 | const TestComponent: React.FC = () => { 189 | useAsyncEffect(function* (onCancel) { 190 | onCancel(() => { 191 | callable("cancel"); 192 | }); 193 | onCancel(() => { 194 | callable("aye"); 195 | }); 196 | yield Promise.resolve("foobars"); 197 | }, []); 198 | return null; 199 | }; 200 | 201 | const { unmount } = render(); 202 | unmount(); 203 | 204 | // wait until next tick 205 | await Promise.resolve(); 206 | 207 | expect(callable).toHaveBeenCalledTimes(1); 208 | expect(callable).toHaveBeenCalledWith("aye"); 209 | }); 210 | 211 | it("does resolve multiple yields in a row", async (done) => { 212 | const TestComponent: React.FC = () => { 213 | useAsyncEffect(function* () { 214 | const value: string = yield Promise.resolve("foobars"); 215 | const value2: string = yield Promise.resolve("henlo"); 216 | const value3: string = yield Promise.resolve("ay"); 217 | expect(value).toEqual("foobars"); 218 | expect(value2).toEqual("henlo"); 219 | expect(value3).toEqual("ay"); 220 | done(); 221 | }, []); 222 | return null; 223 | }; 224 | render(); 225 | }); 226 | 227 | it("does throw promise rejections", async (done) => { 228 | const TestComponent: React.FC = () => { 229 | useAsyncEffect(function* () { 230 | try { 231 | yield Promise.reject(new Error("Something went wrong.")); 232 | done.fail("Should throw"); 233 | } catch (err) { 234 | expect(err.message).toEqual("Something went wrong."); 235 | done(); 236 | } 237 | }, []); 238 | return null; 239 | }; 240 | render(); 241 | }); 242 | 243 | it("does throw promise rejections in loops", async (done) => { 244 | const TestComponent: React.FC = () => { 245 | useAsyncEffect(function* () { 246 | const value1 = yield Promise.resolve('foobar') 247 | expect(value1).toEqual('foobar'); 248 | 249 | for(let i=0; i<=3; i++) { 250 | try { 251 | yield Promise.reject(new Error("Something went wrong.")); 252 | done.fail("Should throw"); 253 | } catch (err) { 254 | expect(err.message).toEqual("Something went wrong."); 255 | } 256 | } 257 | 258 | const value2 = yield Promise.resolve('hello') 259 | expect(value2).toEqual('hello'); 260 | 261 | done(); 262 | }, []); 263 | return null; 264 | }; 265 | render(); 266 | }); 267 | 268 | it("logs error about uncaught promises to the console", async (done) => { 269 | const TestComponent: React.FC = () => { 270 | useAsyncEffect(function* () { 271 | yield Promise.reject(new Error("Something went wrong.")); 272 | done.fail("Should throw."); 273 | }, []); 274 | return null; 275 | }; 276 | const spy = jest.spyOn(console, "error").mockImplementation(); 277 | render(); 278 | await Promise.resolve(); 279 | expect(console.error).toHaveBeenCalledTimes(2); 280 | spy.mockRestore(); 281 | done(); 282 | }); 283 | 284 | it("onCancel is reset after each yield", async (done) => { 285 | const callable = jest.fn(); 286 | const TestComponent: React.FC = () => { 287 | useAsyncEffect(function* (onCancel) { 288 | onCancel(() => { 289 | callable("foo"); 290 | }); 291 | yield Promise.resolve("yey"); 292 | yield Promise.resolve("ay"); 293 | }, []); 294 | return null; 295 | }; 296 | const { unmount } = render(); 297 | await Promise.resolve(); 298 | unmount(); 299 | await Promise.resolve(); 300 | expect(callable).toHaveBeenCalledTimes(0); 301 | done(); 302 | }); 303 | 304 | it("onCancel is run before a promise is resolved", async (done) => { 305 | const callable = jest.fn(); 306 | 307 | const TestComponent: React.FC = () => { 308 | useAsyncEffect(function* (onCancel) { 309 | onCancel(() => { 310 | callable("onCancel"); 311 | }); 312 | yield Promise.resolve().then(() => { 313 | callable("promise"); 314 | }); 315 | }, []); 316 | return null; 317 | }; 318 | 319 | const { unmount } = render(); 320 | unmount(); 321 | 322 | await Promise.resolve(); 323 | 324 | expect(callable).toHaveBeenCalledTimes(2); 325 | expect(callable).toHaveBeenNthCalledWith(1, "onCancel"); 326 | expect(callable).toHaveBeenNthCalledWith(2, "promise"); 327 | 328 | done(); 329 | }); 330 | 331 | it("onCancel second parameter for error handling", async (done) => { 332 | const callable = jest.fn(); 333 | 334 | const TestComponent: React.FC = () => { 335 | useAsyncEffect(function* (onCancel) { 336 | onCancel( 337 | // eslint-disable-next-line @typescript-eslint/no-empty-function 338 | () => {}, 339 | (err) => { 340 | callable(err.message); 341 | } 342 | ); 343 | yield Promise.reject(new Error("lel")); 344 | }, []); 345 | return null; 346 | }; 347 | 348 | const { unmount } = render(); 349 | unmount(); 350 | await Promise.resolve(); 351 | 352 | expect(callable).toHaveBeenCalledTimes(1); 353 | expect(callable).toHaveBeenNthCalledWith(1, "lel"); 354 | 355 | done(); 356 | }); 357 | 358 | it("calls a cleanup function returned by the generator when unmounting", async (done) => { 359 | const callable = jest.fn(); 360 | 361 | const TestComponent: React.FC = () => { 362 | useAsyncEffect(function* () { 363 | yield Promise.resolve(); 364 | return () => { 365 | callable(); 366 | }; 367 | }, []); 368 | return null; 369 | }; 370 | 371 | const { unmount } = render(); 372 | await Promise.resolve(); 373 | 374 | expect(callable).toHaveBeenCalledTimes(0); 375 | 376 | unmount(); 377 | expect(callable).toHaveBeenCalledTimes(1); 378 | done(); 379 | }); 380 | 381 | it("calls a cleanup function returned by the generator when dependencies change", async (done) => { 382 | const callable = jest.fn(); 383 | 384 | let setState: (i: number) => void = () => 1; 385 | 386 | const TestComponent: React.FC = () => { 387 | const [state, _setState] = React.useState(0); 388 | setState = _setState; 389 | useAsyncEffect( 390 | function* () { 391 | yield Promise.resolve(); 392 | return () => { 393 | callable(); 394 | }; 395 | }, 396 | [state] 397 | ); 398 | return null; 399 | }; 400 | 401 | const { unmount } = render(); 402 | await Promise.resolve(); 403 | 404 | act(() => { 405 | setState(1); 406 | }); 407 | await Promise.resolve(); 408 | 409 | expect(callable).toHaveBeenCalledTimes(1); 410 | unmount(); 411 | expect(callable).toHaveBeenCalledTimes(2); 412 | done(); 413 | }); 414 | 415 | it("calls latest generator reference upon dependency change", async (done) => { 416 | const callable = jest.fn(); 417 | let setState: (i: number) => void = () => 1; 418 | 419 | const TestComponent: React.FC = () => { 420 | const [state, _setState] = React.useState(0); 421 | setState = _setState; 422 | useAsyncEffect( 423 | function* () { 424 | yield Promise.resolve(); 425 | callable(state); 426 | }, 427 | [state] 428 | ); 429 | return null; 430 | }; 431 | 432 | const { unmount } = render(); 433 | await Promise.resolve(); 434 | expect(callable).toHaveBeenCalledWith(0); 435 | 436 | act(() => { 437 | setState(1); 438 | }); 439 | await Promise.resolve(); 440 | 441 | expect(callable).toHaveBeenCalledWith(1); 442 | unmount(); 443 | done(); 444 | }); 445 | 446 | it("infers the correct type with the typing helper", async (done) => { 447 | const TestComponent: React.FC = () => { 448 | useAsyncEffect(function* (setErrorHandler, cast) { 449 | const a = yield* cast( 450 | Promise.resolve({ type: "FOO" as const, value: 1 }) 451 | ); 452 | expect(a).toEqual({ type: "FOO", value: 1 }); 453 | }, []); 454 | return null; 455 | }; 456 | 457 | const { unmount } = render(); 458 | await Promise.resolve(); 459 | unmount(); 460 | done(); 461 | }); 462 | -------------------------------------------------------------------------------- /src/use-async-effect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-empty-function 4 | const noop = () => {}; 5 | 6 | type GeneratorReturnValueType = void | (() => void); 7 | 8 | function* cast(input: Promise): Generator, T> { 9 | // eslint-disable-next-line 10 | // @ts-ignore 11 | return yield input; 12 | } 13 | 14 | export const useAsyncEffect = ( 15 | createGenerator: ( 16 | setCancelHandler: ( 17 | onCancel?: null | (() => void), 18 | onCancelError?: null | ((err: Error) => void) 19 | ) => void, 20 | cast: (promise: Promise) => Generator, T> 21 | ) => Iterator, 22 | deps?: React.DependencyList 23 | ): void => { 24 | const generatorRef = useRef(createGenerator); 25 | 26 | useEffect(() => { 27 | generatorRef.current = createGenerator; 28 | }); 29 | 30 | useEffect(() => { 31 | let isCanceled = false; 32 | let onCancel = noop; 33 | let onCancelError = noop as (err: Error) => void; 34 | const generator = generatorRef.current( 35 | (cancelHandler, cancelErrorHandler) => { 36 | onCancel = cancelHandler || noop; 37 | onCancelError = cancelErrorHandler || noop; 38 | }, 39 | cast 40 | ); 41 | let cleanupHandler = noop; 42 | 43 | const run = async () => { 44 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 45 | let result: IteratorResult = { value: undefined, done: false }; 46 | let lastError: Error | undefined = undefined; 47 | 48 | do { 49 | try { 50 | result = !lastError 51 | ? generator.next(result.value) 52 | : generator.throw(lastError); 53 | lastError = undefined; 54 | 55 | if (result.value && result.value.then) { 56 | try { 57 | result.value = await result.value; 58 | } catch (err) { 59 | if (isCanceled) { 60 | onCancelError(err); 61 | return; 62 | } 63 | 64 | lastError = err; 65 | } 66 | } 67 | if (isCanceled) { 68 | return; 69 | } 70 | onCancel = noop; 71 | onCancelError = noop; 72 | } catch (err) { 73 | console.error(`[use-async-effect] Unhandled promise rejection.`); 74 | console.error(err); 75 | return; 76 | } 77 | } while (result.done === false); 78 | if (result.value) { 79 | cleanupHandler = result.value; 80 | } 81 | }; 82 | run(); 83 | 84 | return () => { 85 | isCanceled = true; 86 | onCancel(); 87 | cleanupHandler(); 88 | }; 89 | }, deps); 90 | }; 91 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "esModuleInterop": true, 5 | "declaration": true, 6 | "skipLibCheck": true, 7 | "downlevelIteration": true, 8 | "lib": ["ESNext", "DOM"] 9 | }, 10 | "exclude": ["**/*.spec.tsx", "**/*.spec.ts"] 11 | } 12 | --------------------------------------------------------------------------------