├── .gitignore ├── .prettierrc ├── .travis.yml ├── README.md ├── example ├── .npmignore ├── index.html ├── index.tsx ├── package.json ├── tsconfig.json └── yarn.lock ├── package.json ├── src └── index.ts ├── test └── useAsync.test.ts ├── tsconfig.json ├── tsconfig.test.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .rts2_cache_cjs 5 | .rts2_cache_es 6 | .rts2_cache_esm 7 | .rts2_cache_umd 8 | dist 9 | 10 | example/.cache 11 | 12 | *.iml 13 | .idea 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "useTabs": false, 7 | "bracketSpacing": true, 8 | "jsxBracketSameLine": false 9 | } 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 8 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React-Async-Hook 2 | 3 | [![NPM](https://img.shields.io/npm/dm/react-async-hook.svg)](https://www.npmjs.com/package/react-async-hook) 4 | [![Build Status](https://travis-ci.com/slorber/react-async-hook.svg?branch=master)](https://travis-ci.com/slorber/react-async-hook) 5 | 6 | This **tiny** library only **does one thing**, and **does it well**. 7 | 8 | --- 9 | 10 | # Sponsor 11 | 12 | **[ThisWeekInReact.com](https://thisweekinreact.com/react-async-hook)**: the best newsletter to stay up-to-date with the React ecosystem: 13 | 14 | [![ThisWeekInReact.com banner](https://user-images.githubusercontent.com/749374/136185889-ebdb67cd-ec78-4655-b88b-79a6c134acd2.png)](https://thisweekinreact.com/react-async-hook) 15 | 16 | --- 17 | 18 | Don't expect it to grow in size, it is **feature complete**: 19 | 20 | - Handle fetches (`useAsync`) 21 | - Handle mutations (`useAsyncCallback`) 22 | - Handle cancellation (`useAsyncAbortable` + `AbortController`) 23 | - Handle [race conditions](https://sebastienlorber.com/handling-api-request-race-conditions-in-react) 24 | - Platform agnostic 25 | - Works with any async function, not just backend API calls, not just fetch/axios... 26 | - Very good, native, Typescript support 27 | - Small, no dependency 28 | - Rules of hooks: ESLint find missing dependencies 29 | - Refetch on params change 30 | - Can trigger manual refetch 31 | - Options to customize state updates 32 | - Can mutate state after fetch 33 | - Returned callbacks are stable 34 | 35 | 36 | 37 | ## Small size 38 | 39 | - Way smaller than popular alternatives 40 | - CommonJS + ESM bundles 41 | - Tree-shakable 42 | 43 | | Lib | min | min.gz | 44 | | -------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | 45 | | **Suspend-React** | [![](https://img.shields.io/bundlephobia/min/suspend-react.svg)](https://bundlephobia.com/package/suspend-react) | [![](https://img.shields.io/bundlephobia/minzip/suspend-react.svg)](https://bundlephobia.com/package/suspend-react) | 46 | | **React-Async-Hook** | [![](https://img.shields.io/bundlephobia/min/react-async-hook.svg)](https://bundlephobia.com/package/react-async-hook) | [![](https://img.shields.io/bundlephobia/minzip/react-async-hook.svg)](https://bundlephobia.com/package/react-async-hook) | 47 | | **SWR** | [![](https://img.shields.io/bundlephobia/min/swr.svg)](https://bundlephobia.com/package/swr) | [![](https://img.shields.io/bundlephobia/minzip/swr.svg)](https://bundlephobia.com/package/swr) | 48 | | **React-Query** | [![](https://img.shields.io/bundlephobia/min/react-query.svg)](https://bundlephobia.com/package/react-query) | [![](https://img.shields.io/bundlephobia/minzip/react-query.svg)](https://bundlephobia.com/package/react-query) | 49 | | **React-Async** | [![](https://img.shields.io/bundlephobia/min/react-async.svg)](https://bundlephobia.com/package/react-async) | [![](https://img.shields.io/bundlephobia/minzip/react-async.svg)](https://bundlephobia.com/package/react-async) | 50 | | **Use-HTTP** | [![](https://img.shields.io/bundlephobia/min/use-http.svg)](https://bundlephobia.com/package/use-http) | [![](https://img.shields.io/bundlephobia/minzip/use-http.svg)](https://bundlephobia.com/package/use-http) | 51 | | **Rest-Hooks** | [![](https://img.shields.io/bundlephobia/min/rest-hooks.svg)](https://bundlephobia.com/package/rest-hooks) | [![](https://img.shields.io/bundlephobia/minzip/rest-hooks.svg)](https://bundlephobia.com/package/rest-hooks) | 52 | 53 | --- 54 | 55 | ## Things we don't support (by design): 56 | 57 | - stale-while-revalidate 58 | - refetch on focus / resume 59 | - caching 60 | - polling 61 | - request deduplication 62 | - platform-specific code 63 | - scroll position restoration 64 | - SSR 65 | - router integration for render-as-you-fetch pattern 66 | 67 | You can build on top of this little lib to provide more advanced features (using composition), or move to popular full-featured libraries like [SWR](https://github.com/vercel/swr) or [React-Query](https://github.com/tannerlinsley/react-query). 68 | 69 | ## Use-case: loading async data into a component 70 | 71 | The ability to inject remote/async data into a React component is a very common React need. Later we might support Suspense as well. 72 | 73 | ```tsx 74 | import { useAsync } from 'react-async-hook'; 75 | 76 | const fetchStarwarsHero = async id => 77 | (await fetch(`https://swapi.dev/api/people/${id}/`)).json(); 78 | 79 | const StarwarsHero = ({ id }) => { 80 | const asyncHero = useAsync(fetchStarwarsHero, [id]); 81 | return ( 82 |
83 | {asyncHero.loading &&
Loading
} 84 | {asyncHero.error &&
Error: {asyncHero.error.message}
} 85 | {asyncHero.result && ( 86 |
87 |
Success!
88 |
Name: {asyncHero.result.name}
89 |
90 | )} 91 |
92 | ); 93 | }; 94 | ``` 95 | 96 | ## Use-case: injecting async feedback into buttons 97 | 98 | If you have a Todo app, you might want to show some feedback into the "create todo" button while the creation is pending, and prevent duplicate todo creations by disabling the button. 99 | 100 | Just wire `useAsyncCallback` to your `onClick` prop in your primitive `AppButton` component. The library will show a feedback only if the button onClick callback is async, otherwise it won't do anything. 101 | 102 | ```tsx 103 | import { useAsyncCallback } from 'react-async-hook'; 104 | 105 | const AppButton = ({ onClick, children }) => { 106 | const asyncOnClick = useAsyncCallback(onClick); 107 | return ( 108 | 111 | ); 112 | }; 113 | 114 | const CreateTodoButton = () => ( 115 | { 117 | await createTodoAPI('new todo text'); 118 | }} 119 | > 120 | Create Todo 121 | 122 | ); 123 | ``` 124 | 125 | # Examples 126 | 127 | Examples are running on [this page](https://react-async-hook.netlify.com/) and [implemented here](https://github.com/slorber/react-async-hook/blob/master/example/index.tsx) (in Typescript) 128 | 129 | # Install 130 | 131 | `yarn add react-async-hook` 132 | or 133 | 134 | `npm install react-async-hook --save` 135 | 136 | ## ESLint 137 | 138 | If you use ESLint, use this [`react-hooks/exhaustive-deps`](https://github.com/facebook/react/blob/master/packages/eslint-plugin-react-hooks/README.md#advanced-configuration) setting: 139 | 140 | ```ts 141 | // .eslintrc.js 142 | module.exports = { 143 | // ... 144 | rules: { 145 | 'react-hooks/rules-of-hooks': 'error', 146 | 'react-hooks/exhaustive-deps': [ 147 | 'error', 148 | { 149 | additionalHooks: '(useAsync|useAsyncCallback)', 150 | }, 151 | ], 152 | }, 153 | }; 154 | ``` 155 | 156 | # FAQ 157 | 158 | #### How can I debounce the request 159 | 160 | It is possible to debounce a promise. 161 | 162 | I recommend [awesome-debounce-promise](https://github.com/slorber/awesome-debounce-promise), as it handles nicely potential concurrency issues and have React in mind (particularly the common use-case of a debounced search input/autocomplete) 163 | 164 | As debounced functions are stateful, we have to "store" the debounced function inside a component. We'll use for that [use-constant](https://github.com/Andarist/use-constant) (backed by `useRef`). 165 | 166 | ```tsx 167 | const StarwarsHero = ({ id }) => { 168 | // Create a constant debounced function (created only once per component instance) 169 | const debouncedFetchStarwarsHero = useConstant(() => 170 | AwesomeDebouncePromise(fetchStarwarsHero, 1000) 171 | ); 172 | 173 | // Simply use it with useAsync 174 | const asyncHero = useAsync(debouncedFetchStarwarsHero, [id]); 175 | 176 | return
...
; 177 | }; 178 | ``` 179 | 180 | #### How can I implement a debounced search input / autocomplete? 181 | 182 | This is one of the most common use-case for fetching data + debouncing in a component, and can be implemented easily by composing different libraries. 183 | All this logic can easily be extracted into a single hook that you can reuse. Here is an example: 184 | 185 | ```tsx 186 | const searchStarwarsHero = async ( 187 | text: string, 188 | abortSignal?: AbortSignal 189 | ): Promise => { 190 | const result = await fetch( 191 | `https://swapi.dev/api/people/?search=${encodeURIComponent(text)}`, 192 | { 193 | signal: abortSignal, 194 | } 195 | ); 196 | if (result.status !== 200) { 197 | throw new Error('bad status = ' + result.status); 198 | } 199 | const json = await result.json(); 200 | return json.results; 201 | }; 202 | 203 | const useSearchStarwarsHero = () => { 204 | // Handle the input text state 205 | const [inputText, setInputText] = useState(''); 206 | 207 | // Debounce the original search async function 208 | const debouncedSearchStarwarsHero = useConstant(() => 209 | AwesomeDebouncePromise(searchStarwarsHero, 300) 210 | ); 211 | 212 | const search = useAsyncAbortable( 213 | async (abortSignal, text) => { 214 | // If the input is empty, return nothing immediately (without the debouncing delay!) 215 | if (text.length === 0) { 216 | return []; 217 | } 218 | // Else we use the debounced api 219 | else { 220 | return debouncedSearchStarwarsHero(text, abortSignal); 221 | } 222 | }, 223 | // Ensure a new request is made everytime the text changes (even if it's debounced) 224 | [inputText] 225 | ); 226 | 227 | // Return everything needed for the hook consumer 228 | return { 229 | inputText, 230 | setInputText, 231 | search, 232 | }; 233 | }; 234 | ``` 235 | 236 | And then you can use your hook easily: 237 | 238 | ```tsx 239 | const SearchStarwarsHeroExample = () => { 240 | const { inputText, setInputText, search } = useSearchStarwarsHero(); 241 | return ( 242 |
243 | setInputText(e.target.value)} /> 244 |
245 | {search.loading &&
...
} 246 | {search.error &&
Error: {search.error.message}
} 247 | {search.result && ( 248 |
249 |
Results: {search.result.length}
250 |
    251 | {search.result.map(hero => ( 252 |
  • {hero.name}
  • 253 | ))} 254 |
255 |
256 | )} 257 |
258 |
259 | ); 260 | }; 261 | ``` 262 | 263 | #### How to use request cancellation? 264 | 265 | You can use the `useAsyncAbortable` alternative. The async function provided will receive `(abortSignal, ...params)` . 266 | 267 | The library will take care of triggering the abort signal whenever a new async call is made so that only the last request is not cancelled. 268 | It is your responsibility to wire the abort signal appropriately. 269 | 270 | ```tsx 271 | const StarwarsHero = ({ id }) => { 272 | const asyncHero = useAsyncAbortable( 273 | async (abortSignal, id) => { 274 | const result = await fetch(`https://swapi.dev/api/people/${id}/`, { 275 | signal: abortSignal, 276 | }); 277 | if (result.status !== 200) { 278 | throw new Error('bad status = ' + result.status); 279 | } 280 | return result.json(); 281 | }, 282 | [id] 283 | ); 284 | 285 | return
...
; 286 | }; 287 | ``` 288 | 289 | #### How can I keep previous results available while a new request is pending? 290 | 291 | It can be annoying to have the previous async call result be "erased" everytime a new call is triggered (default strategy). 292 | If you are implementing some kind of search/autocomplete dropdown, it means a spinner will appear everytime the user types a new char, giving a bad UX effect. 293 | It is possible to provide your own "merge" strategies. 294 | The following will ensure that on new calls, the previous result is kept until the new call result is received 295 | 296 | ```tsx 297 | const StarwarsHero = ({ id }) => { 298 | const asyncHero = useAsync(fetchStarwarsHero, [id], { 299 | setLoading: state => ({ ...state, loading: true }), 300 | }); 301 | return
...
; 302 | }; 303 | ``` 304 | 305 | #### How to refresh / refetch the data? 306 | 307 | If your params are not changing, yet you need to refresh the data, you can call `execute()` 308 | 309 | ```tsx 310 | const StarwarsHero = ({ id }) => { 311 | const asyncHero = useAsync(fetchStarwarsHero, [id]); 312 | 313 | return
asyncHero.execute()}>...
; 314 | }; 315 | ``` 316 | 317 | #### How to handle conditional fetch? 318 | 319 | You can enable/disable the fetch logic directly inside the async callback. In some cases you know your API won't return anything useful. 320 | 321 | ```tsx 322 | const asyncSearchResults = useAsync(async () => { 323 | // It's useless to call a search API with an empty text 324 | if (text.length === 0) { 325 | return []; 326 | } else { 327 | return getSearchResultsAsync(text); 328 | } 329 | }, [text]); 330 | ``` 331 | 332 | #### How to have better control when things get fetched/refetched? 333 | 334 | Sometimes you end up in situations where the function tries to fetch too often, or not often, because your dependency array changes and you don't know how to handle this. 335 | 336 | In this case you'd better use a closure with no arg define in the dependency array which params should trigger a refetch: 337 | 338 | Here, both `state.a` and `state.b` will trigger a refetch, despite b is not passed to the async fetch function. 339 | 340 | ```tsx 341 | const asyncSomething = useAsync(() => fetchSomething(state.a), [ 342 | state.a, 343 | state.b, 344 | ]); 345 | ``` 346 | 347 | Here, only `state.a` will trigger a refetch, despite b being passed to the async fetch function. 348 | 349 | ```tsx 350 | const asyncSomething = useAsync(() => fetchSomething(state.a, state.b), [ 351 | state.a, 352 | ]); 353 | ``` 354 | 355 | Note you can also use this to "build" a more complex payload. Using `useMemo` does not guarantee the memoized value will not be cleared, so it's better to do: 356 | 357 | ```tsx 358 | const asyncSomething = useAsync(async () => { 359 | const payload = buildFetchPayload(state); 360 | const result = await fetchSomething(payload); 361 | return result; 362 | }), [state.a, state.b, state.whateverNeedToTriggerRefetch]); 363 | ``` 364 | 365 | You can also use `useAsyncCallback` to decide yourself manually when a fetch should be done: 366 | 367 | ```tsx 368 | const asyncSomething = useAsyncCallback(async () => { 369 | const payload = buildFetchPayload(state); 370 | const result = await fetchSomething(payload); 371 | return result; 372 | })); 373 | 374 | // Call this manually whenever you need: 375 | asyncSomething.execute(); 376 | ``` 377 | 378 | #### How to support retry? 379 | 380 | Use a lib that adds retry feature to async/promises directly. 381 | 382 | # License 383 | 384 | MIT 385 | 386 | # Hire a freelance expert 387 | 388 | Looking for a React/ReactNative freelance expert with more than 5 years production experience? 389 | Contact me from my [website](https://sebastienlorber.com/) or with [Twitter](https://twitter.com/sebastienlorber). 390 | -------------------------------------------------------------------------------- /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist 4 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Playground 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie11'; 2 | import * as React from 'react'; 3 | import * as ReactDOM from 'react-dom'; 4 | import '@babel/polyfill'; 5 | 6 | import { 7 | useAsync, 8 | useAsyncAbortable, 9 | useAsyncCallback, 10 | UseAsyncReturn, 11 | } from 'react-async-hook'; 12 | 13 | import { ReactNode, useState } from 'react'; 14 | import useConstant from 'use-constant'; 15 | import AwesomeDebouncePromise from 'awesome-debounce-promise'; 16 | 17 | const AppButton = ({ onClick, children }) => { 18 | const asyncOnClick = useAsyncCallback(onClick); 19 | return ( 20 | 27 | ); 28 | }; 29 | 30 | type ExampleType = 'basic' | 'abortable' | 'debounced' | 'merge'; 31 | 32 | type StarwarsHero = { 33 | id: string; 34 | name: string; 35 | }; 36 | 37 | const fetchStarwarsHero = async ( 38 | id: string, 39 | abortSignal?: AbortSignal 40 | ): Promise => { 41 | const result = await fetch(`https://swapi.dev/api/people/${id}/`, { 42 | signal: abortSignal, 43 | }); 44 | if (result.status !== 200) { 45 | throw new Error('bad status = ' + result.status); 46 | } 47 | return result.json(); 48 | }; 49 | 50 | const searchStarwarsHero = async ( 51 | text: string, 52 | abortSignal?: AbortSignal 53 | ): Promise => { 54 | const result = await fetch( 55 | `https://swapi.dev/api/people/?search=${encodeURIComponent(text)}`, 56 | { 57 | signal: abortSignal, 58 | } 59 | ); 60 | if (result.status !== 200) { 61 | throw new Error('bad status = ' + result.status); 62 | } 63 | const json = await result.json(); 64 | return json.results; 65 | }; 66 | 67 | const HeroContainer = ({ children }) => ( 68 |
81 | {children} 82 |
83 | ); 84 | 85 | const Example = ({ 86 | title, 87 | children, 88 | }: { 89 | title: string; 90 | children: ReactNode; 91 | }) => ( 92 |
99 |

106 | {title} 107 |

108 |
114 | {children} 115 |
116 |
117 | ); 118 | 119 | const StarwarsHeroRender = ({ 120 | id, 121 | asyncHero, 122 | }: { 123 | id: string; 124 | asyncHero: UseAsyncReturn; 125 | }) => { 126 | return ( 127 |
128 | {asyncHero.loading &&
Loading
} 129 | {asyncHero.error &&
Error: {asyncHero.error.message}
} 130 | {asyncHero.result && ( 131 |
132 |
Success!
133 |
Id: {id}
134 |
Name: {asyncHero.result.name}
135 |
136 | )} 137 |
138 | ); 139 | }; 140 | 141 | const StarwarsHeroLoaderBasic = ({ id }: { id: string }) => { 142 | const asyncHero = useAsync(fetchStarwarsHero, [id]); 143 | return ; 144 | }; 145 | 146 | const StarwarsHeroLoaderDebounced = ({ id }: { id: string }) => { 147 | const debouncedFetchStarwarsHero = useConstant(() => 148 | AwesomeDebouncePromise(fetchStarwarsHero, 1000) 149 | ); 150 | const asyncHero = useAsync(debouncedFetchStarwarsHero, [id]); 151 | return ; 152 | }; 153 | 154 | const StarwarsHeroLoaderAbortable = ({ id }: { id: string }) => { 155 | const asyncHero = useAsyncAbortable( 156 | async (abortSignal, id) => fetchStarwarsHero(id, abortSignal), 157 | [id] 158 | ); 159 | return ; 160 | }; 161 | 162 | const StarwarsHeroLoaderMerge = ({ id }: { id: string }) => { 163 | const asyncHero = useAsync(fetchStarwarsHero, [id], { 164 | setLoading: state => ({ ...state, loading: true }), 165 | }); 166 | return ; 167 | }; 168 | 169 | const StarwarsHeroLoader = ({ 170 | id, 171 | exampleType, 172 | }: { 173 | id: string; 174 | exampleType: ExampleType; 175 | }) => { 176 | if (exampleType === 'basic') { 177 | return ; 178 | } else if (exampleType === 'debounced') { 179 | return ; 180 | } else if (exampleType === 'abortable') { 181 | return ; 182 | } else if (exampleType === 'merge') { 183 | return ; 184 | } else { 185 | throw new Error('unknown exampleType=' + exampleType); 186 | } 187 | }; 188 | 189 | const StarwarsSliderExample = ({ 190 | title, 191 | exampleType, 192 | }: { 193 | title: string; 194 | exampleType: ExampleType; 195 | }) => { 196 | const [heroId, setHeroId] = useState(1); 197 | const next = () => setHeroId(heroId + 1); 198 | const previous = () => setHeroId(heroId - 1); 199 | 200 | const buttonStyle = { 201 | border: 'solid', 202 | cursor: 'pointer', 203 | borderRadius: 50, 204 | padding: 10, 205 | margin: 10, 206 | }; 207 | return ( 208 | 209 |
210 |
211 | Previous 212 |
213 |
214 | Next 215 |
216 |
217 | 218 |
219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 |
229 |
230 | ); 231 | }; 232 | 233 | const useSearchStarwarsHero = () => { 234 | // Handle the input text state 235 | const [inputText, setInputText] = useState(''); 236 | 237 | // Debounce the original search async function 238 | const debouncedSearchStarwarsHero = useConstant(() => 239 | AwesomeDebouncePromise(searchStarwarsHero, 300) 240 | ); 241 | 242 | const search = useAsyncAbortable( 243 | async (abortSignal, text) => { 244 | // If the input is empty, return nothing immediately (without the debouncing delay!) 245 | if (text.length === 0) { 246 | return []; 247 | } 248 | // Else we use the debounced api 249 | else { 250 | return debouncedSearchStarwarsHero(text, abortSignal); 251 | } 252 | }, 253 | // Ensure a new request is made everytime the text changes (even if it's debounced) 254 | [inputText] 255 | ); 256 | 257 | // Return everything needed for the hook consumer 258 | return { 259 | inputText, 260 | setInputText, 261 | search, 262 | }; 263 | }; 264 | 265 | const SearchStarwarsHeroExample = () => { 266 | const { inputText, setInputText, search } = useSearchStarwarsHero(); 267 | return ( 268 | 269 | setInputText(e.target.value)} 272 | placeholder="Search starwars hero" 273 | style={{ 274 | marginTop: 20, 275 | padding: 10, 276 | border: 'solid thin', 277 | borderRadius: 5, 278 | width: 300, 279 | }} 280 | /> 281 |
282 | {search.loading &&
...
} 283 | {search.error &&
Error: {search.error.message}
} 284 | {search.result && ( 285 |
286 |
Results: {search.result.length}
287 |
    288 | {search.result.map(hero => ( 289 |
  • {hero.name}
  • 290 | ))} 291 |
292 |
293 | )} 294 |
295 |
296 | ); 297 | }; 298 | 299 | const App = () => ( 300 |
309 |
310 |

311 | Example page for{' '} 312 | 313 | react-async-hook 314 | {' '} 315 | ( 316 | 320 | source 321 | 322 | ) 323 |

324 |

325 | by{' '} 326 | 327 | @sebastienlorber 328 | 329 |

330 |
331 | 332 | 333 | 334 | 335 | { 337 | await new Promise(resolve => setTimeout(resolve, 1000)); 338 | }} 339 | > 340 | Do something async 341 | 342 | 343 | 344 | 348 | 352 | 356 | 360 |
361 | ); 362 | 363 | ReactDOM.render(, document.getElementById('root')); 364 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "@babel/polyfill": "^7.4.4", 12 | "awesome-debounce-promise": "^2.1.0", 13 | "react": "^16.8.6", 14 | "react-app-polyfill": "^1.0.0", 15 | "react-async-hook": "latest", 16 | "react-dom": "^16.8.6", 17 | "use-constant": "^1.0.0" 18 | }, 19 | "devDependencies": { 20 | "@types/react": "^16.8.15", 21 | "@types/react-dom": "^16.8.4", 22 | "parcel": "^1.12.3", 23 | "typescript": "^3.4.5" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "baseUrl": ".", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-async-hook", 3 | "version": "4.0.0", 4 | "description": "Async hook", 5 | "author": "Sébastien Lorber", 6 | "license": "MIT", 7 | "repository": "http://github.com/slorber/react-async-hook", 8 | "keywords": [ 9 | "react-async-hook", 10 | "async", 11 | "fetch", 12 | "axios", 13 | "promise", 14 | "react", 15 | "react-native", 16 | "reactjs", 17 | "reactnative", 18 | "hooks", 19 | "hook", 20 | "useAsync", 21 | "useState", 22 | "useFetch", 23 | "usePromise", 24 | "use-async", 25 | "use-state", 26 | "use-fetch", 27 | "use-promise" 28 | ], 29 | "engines": { 30 | "node": ">=8", 31 | "npm": ">=5" 32 | }, 33 | "main": "dist/index.js", 34 | "module": "dist/react-async-hook.esm.js", 35 | "typings": "dist/index.d.ts", 36 | "files": [ 37 | "dist" 38 | ], 39 | "scripts": { 40 | "start": "tsdx watch", 41 | "build": "tsdx build", 42 | "test": "tsdx test --env=jsdom" 43 | }, 44 | "peerDependencies": { 45 | "react": ">=16.8" 46 | }, 47 | "husky": { 48 | "hooks": { 49 | "pre-commit": "pretty-quick --staged" 50 | } 51 | }, 52 | "prettier": { 53 | "printWidth": 80, 54 | "semi": true, 55 | "singleQuote": true, 56 | "trailingComma": "es5" 57 | }, 58 | "jest": { 59 | "globals": { 60 | "ts-jest": { 61 | "tsConfig": "tsconfig.test.json" 62 | } 63 | }, 64 | "setupFilesAfterEnv": [ 65 | "@testing-library/jest-dom/extend-expect" 66 | ] 67 | }, 68 | "devDependencies": { 69 | "@testing-library/jest-dom": "^4.1.2", 70 | "@testing-library/react": "^9.3.0", 71 | "@testing-library/react-hooks": "^3.1.0", 72 | "@types/jest": "^24.0.12", 73 | "@types/react": "^16.9.9", 74 | "@types/react-dom": "^16.9.2", 75 | "husky": "^2.2.0", 76 | "prettier": "^1.17.0", 77 | "pretty-quick": "^1.10.0", 78 | "react": "^16.10.2", 79 | "react-dom": "^16.10.2", 80 | "react-test-renderer": "^16.10.2", 81 | "tsdx": "^0.7.2", 82 | "tslib": "^1.9.3", 83 | "typescript": "^3.4.5" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Dispatch, 3 | SetStateAction, 4 | useCallback, 5 | useEffect, 6 | useLayoutEffect, 7 | useRef, 8 | useState, 9 | } from 'react'; 10 | 11 | // See https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85 12 | const useIsomorphicLayoutEffect = 13 | typeof window !== 'undefined' && 14 | typeof window.document !== 'undefined' && 15 | typeof window.document.createElement !== 'undefined' 16 | ? useLayoutEffect 17 | : useEffect; 18 | 19 | // Assign current value to a ref and returns a stable getter to get the latest value. 20 | // This way we are sure to always get latest value provided to hook and 21 | // avoid weird issues due to closures capturing stale values... 22 | // See https://github.com/facebook/react/issues/16956 23 | // See https://overreacted.io/making-setinterval-declarative-with-react-hooks/ 24 | const useGetter = (t: T) => { 25 | const ref = useRef(t); 26 | useIsomorphicLayoutEffect(() => { 27 | ref.current = t; 28 | }); 29 | return useCallback(() => ref.current, [ref]); 30 | }; 31 | 32 | type UnknownResult = unknown; 33 | 34 | // Convenient to avoid declaring the type of args, which may help reduce type boilerplate 35 | //type UnknownArgs = unknown[]; 36 | // TODO unfortunately it seems required for now if we want default param to work... 37 | // See https://twitter.com/sebastienlorber/status/1170003594894106624 38 | type UnknownArgs = any[]; 39 | 40 | export type AsyncStateStatus = 41 | | 'not-requested' 42 | | 'loading' 43 | | 'success' 44 | | 'error'; 45 | 46 | export type AsyncState = { 47 | status: AsyncStateStatus; 48 | loading: boolean; 49 | error: Error | undefined; 50 | result: R | undefined; 51 | }; 52 | type SetLoading = (asyncState: AsyncState) => AsyncState; 53 | type SetResult = (result: R, asyncState: AsyncState) => AsyncState; 54 | type SetError = (error: Error, asyncState: AsyncState) => AsyncState; 55 | 56 | type MaybePromise = Promise | T; 57 | 58 | type PromiseCallbackOptions = { 59 | // Permit to know if the success/error belongs to the last async call 60 | isCurrent: () => boolean; 61 | 62 | // TODO this can be convenient but need some refactor 63 | // params: Args; 64 | }; 65 | 66 | export type UseAsyncOptionsNormalized = { 67 | initialState: (options?: UseAsyncOptionsNormalized) => AsyncState; 68 | executeOnMount: boolean; 69 | executeOnUpdate: boolean; 70 | setLoading: SetLoading; 71 | setResult: SetResult; 72 | setError: SetError; 73 | onSuccess: (r: R, options: PromiseCallbackOptions) => void; 74 | onError: (e: Error, options: PromiseCallbackOptions) => void; 75 | }; 76 | export type UseAsyncOptions = 77 | | Partial> 78 | | undefined 79 | | null; 80 | 81 | const InitialAsyncState: AsyncState = { 82 | status: 'not-requested', 83 | loading: false, 84 | result: undefined, 85 | error: undefined, 86 | }; 87 | 88 | const InitialAsyncLoadingState: AsyncState = { 89 | status: 'loading', 90 | loading: true, 91 | result: undefined, 92 | error: undefined, 93 | }; 94 | 95 | const defaultSetLoading: SetLoading = _asyncState => 96 | InitialAsyncLoadingState; 97 | 98 | const defaultSetResult: SetResult = (result, _asyncState) => ({ 99 | status: 'success', 100 | loading: false, 101 | result: result, 102 | error: undefined, 103 | }); 104 | 105 | const defaultSetError: SetError = (error, _asyncState) => ({ 106 | status: 'error', 107 | loading: false, 108 | result: undefined, 109 | error: error, 110 | }); 111 | 112 | const noop = () => {}; 113 | 114 | const DefaultOptions: UseAsyncOptionsNormalized = { 115 | initialState: options => 116 | options && options.executeOnMount 117 | ? InitialAsyncLoadingState 118 | : InitialAsyncState, 119 | executeOnMount: true, 120 | executeOnUpdate: true, 121 | setLoading: defaultSetLoading, 122 | setResult: defaultSetResult, 123 | setError: defaultSetError, 124 | onSuccess: noop, 125 | onError: noop, 126 | }; 127 | 128 | const normalizeOptions = ( 129 | options: UseAsyncOptions 130 | ): UseAsyncOptionsNormalized => ({ 131 | ...DefaultOptions, 132 | ...options, 133 | }); 134 | 135 | type UseAsyncStateResult = { 136 | value: AsyncState; 137 | set: Dispatch>>; 138 | merge: (value: Partial>) => void; 139 | reset: () => void; 140 | setLoading: () => void; 141 | setResult: (r: R) => void; 142 | setError: (e: Error) => void; 143 | }; 144 | const useAsyncState = ( 145 | options: UseAsyncOptionsNormalized 146 | ): UseAsyncStateResult => { 147 | const [value, setValue] = useState>(() => 148 | options.initialState(options) 149 | ); 150 | 151 | const reset = useCallback(() => setValue(options.initialState(options)), [ 152 | setValue, 153 | options, 154 | ]); 155 | 156 | const setLoading = useCallback(() => setValue(options.setLoading(value)), [ 157 | value, 158 | setValue, 159 | ]); 160 | const setResult = useCallback( 161 | (result: R) => setValue(options.setResult(result, value)), 162 | [value, setValue] 163 | ); 164 | 165 | const setError = useCallback( 166 | (error: Error) => setValue(options.setError(error, value)), 167 | [value, setValue] 168 | ); 169 | 170 | const merge = useCallback( 171 | (state: Partial>) => 172 | setValue({ 173 | ...value, 174 | ...state, 175 | }), 176 | [value, setValue] 177 | ); 178 | 179 | return { 180 | value, 181 | set: setValue, 182 | merge, 183 | reset, 184 | setLoading, 185 | setResult, 186 | setError, 187 | }; 188 | }; 189 | 190 | const useIsMounted = (): (() => boolean) => { 191 | const ref = useRef(false); 192 | useEffect(() => { 193 | ref.current = true; 194 | return () => { 195 | ref.current = false; 196 | }; 197 | }, []); 198 | return () => ref.current; 199 | }; 200 | 201 | type UseCurrentPromiseReturn = { 202 | set: (promise: Promise) => void; 203 | get: () => Promise | null; 204 | is: (promise: Promise) => boolean; 205 | }; 206 | const useCurrentPromise = (): UseCurrentPromiseReturn => { 207 | const ref = useRef | null>(null); 208 | return { 209 | set: promise => (ref.current = promise), 210 | get: () => ref.current, 211 | is: promise => ref.current === promise, 212 | }; 213 | }; 214 | 215 | export type UseAsyncReturn< 216 | R = UnknownResult, 217 | Args extends any[] = UnknownArgs 218 | > = AsyncState & { 219 | set: (value: AsyncState) => void; 220 | merge: (value: Partial>) => void; 221 | reset: () => void; 222 | execute: (...args: Args) => Promise; 223 | currentPromise: Promise | null; 224 | currentParams: Args | null; 225 | }; 226 | 227 | // Relaxed interface which accept both async and sync functions 228 | // Accepting sync function is convenient for useAsyncCallback 229 | const useAsyncInternal = ( 230 | asyncFunction: (...args: Args) => MaybePromise, 231 | params: Args, 232 | options?: UseAsyncOptions 233 | ): UseAsyncReturn => { 234 | // Fallback missing params, only for JS users forgetting the deps array, to prevent infinite loops 235 | // https://github.com/slorber/react-async-hook/issues/27 236 | // @ts-ignore 237 | !params && (params = []); 238 | 239 | const normalizedOptions = normalizeOptions(options); 240 | 241 | const [currentParams, setCurrentParams] = useState(null); 242 | 243 | const AsyncState = useAsyncState(normalizedOptions); 244 | 245 | const isMounted = useIsMounted(); 246 | const CurrentPromise = useCurrentPromise(); 247 | 248 | // We only want to handle the promise result/error 249 | // if it is the last operation and the comp is still mounted 250 | const shouldHandlePromise = (p: Promise) => 251 | isMounted() && CurrentPromise.is(p); 252 | 253 | const executeAsyncOperation = (...args: Args): Promise => { 254 | // async ensures errors thrown synchronously are caught (ie, bug when formatting api payloads) 255 | // async ensures promise-like and synchronous functions are handled correctly too 256 | // see https://github.com/slorber/react-async-hook/issues/24 257 | const promise: Promise = (async () => asyncFunction(...args))(); 258 | setCurrentParams(args); 259 | CurrentPromise.set(promise); 260 | AsyncState.setLoading(); 261 | promise.then( 262 | result => { 263 | if (shouldHandlePromise(promise)) { 264 | AsyncState.setResult(result); 265 | } 266 | normalizedOptions.onSuccess(result, { 267 | isCurrent: () => CurrentPromise.is(promise), 268 | }); 269 | }, 270 | error => { 271 | if (shouldHandlePromise(promise)) { 272 | AsyncState.setError(error); 273 | } 274 | normalizedOptions.onError(error, { 275 | isCurrent: () => CurrentPromise.is(promise), 276 | }); 277 | } 278 | ); 279 | return promise; 280 | }; 281 | 282 | const getLatestExecuteAsyncOperation = useGetter(executeAsyncOperation); 283 | 284 | const executeAsyncOperationMemo: (...args: Args) => Promise = useCallback( 285 | (...args) => getLatestExecuteAsyncOperation()(...args), 286 | [getLatestExecuteAsyncOperation] 287 | ); 288 | 289 | // Keep this outside useEffect, because inside isMounted() 290 | // will be true as the component is already mounted when it's run 291 | const isMounting = !isMounted(); 292 | useEffect(() => { 293 | const execute = () => getLatestExecuteAsyncOperation()(...params); 294 | isMounting && normalizedOptions.executeOnMount && execute(); 295 | !isMounting && normalizedOptions.executeOnUpdate && execute(); 296 | }, params); 297 | 298 | return { 299 | ...AsyncState.value, 300 | set: AsyncState.set, 301 | merge: AsyncState.merge, 302 | reset: AsyncState.reset, 303 | execute: executeAsyncOperationMemo, 304 | currentPromise: CurrentPromise.get(), 305 | currentParams, 306 | }; 307 | }; 308 | 309 | // override to allow passing an async function with no args: 310 | // gives more user-freedom to create an inline async function 311 | export function useAsync( 312 | asyncFunction: () => Promise, 313 | params: Args, 314 | options?: UseAsyncOptions 315 | ): UseAsyncReturn; 316 | export function useAsync( 317 | asyncFunction: (...args: Args) => Promise, 318 | params: Args, 319 | options?: UseAsyncOptions 320 | ): UseAsyncReturn; 321 | 322 | export function useAsync( 323 | asyncFunction: (...args: Args) => Promise, 324 | params: Args, 325 | options?: UseAsyncOptions 326 | ): UseAsyncReturn { 327 | return useAsyncInternal(asyncFunction, params, options); 328 | } 329 | 330 | type AddArg = ((h: H, ...t: T) => void) extends (( 331 | ...r: infer R 332 | ) => void) 333 | ? R 334 | : never; 335 | 336 | export const useAsyncAbortable = < 337 | R = UnknownResult, 338 | Args extends any[] = UnknownArgs 339 | >( 340 | asyncFunction: (...args: AddArg) => Promise, 341 | params: Args, 342 | options?: UseAsyncOptions 343 | ): UseAsyncReturn => { 344 | const abortControllerRef = useRef(); 345 | 346 | // Wrap the original async function and enhance it with abortion login 347 | const asyncFunctionWrapper: (...args: Args) => Promise = async ( 348 | ...p: Args 349 | ) => { 350 | // Cancel previous async call 351 | if (abortControllerRef.current) { 352 | abortControllerRef.current.abort(); 353 | } 354 | // Create/store new abort controller for next async call 355 | const abortController = new AbortController(); 356 | abortControllerRef.current = abortController; 357 | 358 | try { 359 | // @ts-ignore // TODO how to type this? 360 | return await asyncFunction(abortController.signal, ...p); 361 | } finally { 362 | // Unset abortController ref if response is already there, 363 | // as it's not needed anymore to try to abort it (would it be no-op?) 364 | if (abortControllerRef.current === abortController) { 365 | abortControllerRef.current = undefined; 366 | } 367 | } 368 | }; 369 | 370 | return useAsync(asyncFunctionWrapper, params, options); 371 | }; 372 | 373 | // keep compat with TS < 3.5 374 | type LegacyOmit = Pick>; 375 | 376 | // Some options are not allowed for useAsyncCallback 377 | export type UseAsyncCallbackOptions = 378 | | LegacyOmit< 379 | Partial>, 380 | 'executeOnMount' | 'executeOnUpdate' | 'initialState' 381 | > 382 | | undefined 383 | | null; 384 | 385 | export const useAsyncCallback = < 386 | R = UnknownResult, 387 | Args extends any[] = UnknownArgs 388 | >( 389 | asyncFunction: (...args: Args) => MaybePromise, 390 | options?: UseAsyncCallbackOptions 391 | ): UseAsyncReturn => { 392 | return useAsyncInternal( 393 | asyncFunction, 394 | // Hacky but in such case we don't need the params, 395 | // because async function is only executed manually 396 | [] as any, 397 | { 398 | ...options, 399 | executeOnMount: false, 400 | executeOnUpdate: false, 401 | } 402 | ); 403 | }; 404 | -------------------------------------------------------------------------------- /test/useAsync.test.ts: -------------------------------------------------------------------------------- 1 | import { useAsync } from '../src'; 2 | import { renderHook } from '@testing-library/react-hooks'; 3 | 4 | const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); 5 | 6 | interface StarwarsHero { 7 | name: string; 8 | } 9 | 10 | export const generateFakeResults = (pageSize: number = 5): StarwarsHero[] => 11 | // @ts-ignore 12 | [...Array(pageSize).keys()].map(n => ({ 13 | id: n + 1, 14 | name: `Starwars Hero ${n + 1}`, 15 | })); 16 | 17 | export const generateFakeResultsAsync = async ( 18 | pageSize: number = 5, 19 | delay = 100 20 | ): Promise => { 21 | await sleep(delay); 22 | return generateFakeResults(pageSize); 23 | }; 24 | 25 | describe('useAync', () => { 26 | const fakeResults = generateFakeResults(); 27 | 28 | it('should have a useAsync hook', () => { 29 | expect(useAsync).toBeDefined(); 30 | }); 31 | 32 | it('should resolve a successful resolved promise', async () => { 33 | const onSuccess = jest.fn(); 34 | const onError = jest.fn(); 35 | 36 | const { result, waitForNextUpdate } = renderHook(() => 37 | useAsync( 38 | async () => { 39 | return Promise.resolve(fakeResults); 40 | }, 41 | [], 42 | { 43 | onSuccess: () => onSuccess(), 44 | onError: () => onError(), 45 | } 46 | ) 47 | ); 48 | 49 | expect(result.current.loading).toBe(true); 50 | 51 | await waitForNextUpdate(); 52 | 53 | expect(result.current.result).toEqual(fakeResults); 54 | expect(result.current.loading).toBe(false); 55 | expect(result.current.error).toBeUndefined(); 56 | expect(onSuccess).toHaveBeenCalled(); 57 | expect(onError).not.toHaveBeenCalled(); 58 | }); 59 | 60 | it('should resolve a successful real-world request + handle params update', async () => { 61 | const onSuccess = jest.fn(); 62 | const onError = jest.fn(); 63 | 64 | const { result, waitForNextUpdate, rerender } = renderHook( 65 | ({ pageSize }: { pageSize: number }) => 66 | useAsync(() => generateFakeResultsAsync(pageSize), [pageSize], { 67 | onSuccess: () => onSuccess(), 68 | onError: () => onError(), 69 | }), 70 | { 71 | initialProps: { pageSize: 5 }, 72 | } 73 | ); 74 | 75 | expect(result.current.loading).toBe(true); 76 | await waitForNextUpdate(); 77 | expect(result.current.result).toEqual(generateFakeResults(5)); 78 | expect(result.current.loading).toBe(false); 79 | expect(result.current.error).toBeUndefined(); 80 | expect(onSuccess).toHaveBeenCalledTimes(1); 81 | expect(onError).not.toHaveBeenCalled(); 82 | 83 | rerender({ 84 | pageSize: 6, 85 | }); 86 | 87 | expect(result.current.loading).toBe(true); 88 | await waitForNextUpdate(); 89 | expect(result.current.result).toEqual(generateFakeResults(6)); 90 | expect(result.current.loading).toBe(false); 91 | expect(result.current.error).toBeUndefined(); 92 | expect(onSuccess).toHaveBeenCalledTimes(2); 93 | expect(onError).not.toHaveBeenCalled(); 94 | }); 95 | 96 | // See https://github.com/slorber/react-async-hook/issues/27 97 | it('should handle async function without dependency array (shortcut) ', async () => { 98 | const getFakeResultsAsync = () => Promise.resolve(fakeResults); 99 | 100 | const { result, waitForNextUpdate } = renderHook(() => 101 | // It is better to always required a deps array for TS users, but JS users might forget it so... 102 | // Should we allow this "shortcut" for TS users too? I'd rather not 103 | // @ts-ignore 104 | useAsync(getFakeResultsAsync) 105 | ); 106 | 107 | expect(result.current.loading).toBe(true); 108 | 109 | await waitForNextUpdate(); 110 | 111 | expect(result.current.result).toEqual(fakeResults); 112 | expect(result.current.loading).toBe(false); 113 | expect(result.current.error).toBeUndefined(); 114 | }); 115 | 116 | it('should resolve a successful real-world requests with potential race conditions', async () => { 117 | const onSuccess = jest.fn(); 118 | const onError = jest.fn(); 119 | 120 | const { result, waitForNextUpdate, rerender } = renderHook( 121 | ({ pageSize, delay }: { pageSize: number; delay: number }) => 122 | useAsync( 123 | () => generateFakeResultsAsync(pageSize, delay), 124 | [pageSize, delay], 125 | { 126 | onSuccess: () => onSuccess(), 127 | onError: () => onError(), 128 | } 129 | ), 130 | { 131 | initialProps: { pageSize: 5, delay: 200 }, 132 | } 133 | ); 134 | 135 | rerender({ 136 | pageSize: 6, 137 | delay: 100, 138 | }); 139 | 140 | rerender({ 141 | pageSize: 7, 142 | delay: 0, 143 | }); 144 | 145 | expect(result.current.loading).toBe(true); 146 | await waitForNextUpdate(); 147 | expect(result.current.result).toEqual(generateFakeResults(7)); 148 | expect(result.current.loading).toBe(false); 149 | expect(result.current.error).toBeUndefined(); 150 | expect(onSuccess).toHaveBeenCalledTimes(1); 151 | expect(onError).not.toHaveBeenCalled(); 152 | 153 | await sleep(100); 154 | expect(onSuccess).toHaveBeenCalledTimes(2); 155 | 156 | await sleep(100); 157 | expect(onSuccess).toHaveBeenCalledTimes(3); 158 | 159 | expect(result.current.result).toEqual(generateFakeResults(7)); 160 | expect(result.current.loading).toBe(false); 161 | expect(result.current.error).toBeUndefined(); 162 | }); 163 | 164 | // Test added because Jest mocks can return promises that arre not instances of Promises 165 | // This test ensures better testability of user code 166 | // See https://github.com/slorber/react-async-hook/issues/24 167 | it('should resolve a successful Jest mocked resolved value', async () => { 168 | const onSuccess = jest.fn(); 169 | const onError = jest.fn(); 170 | 171 | const asyncFunction = jest.fn().mockResolvedValue(fakeResults); 172 | 173 | const { result, waitForNextUpdate } = renderHook(() => 174 | useAsync(asyncFunction, [], { 175 | onSuccess: () => onSuccess(), 176 | onError: () => onError(), 177 | }) 178 | ); 179 | 180 | expect(result.current.loading).toBe(true); 181 | 182 | await waitForNextUpdate(); 183 | 184 | expect(result.current.result).toEqual(fakeResults); 185 | expect(result.current.loading).toBe(false); 186 | expect(result.current.error).toBeUndefined(); 187 | expect(onSuccess).toHaveBeenCalled(); 188 | expect(onError).not.toHaveBeenCalled(); 189 | }); 190 | 191 | // TODO legacy: should we remove this behavior? 192 | it('should resolve a successful synchronous request', async () => { 193 | const onSuccess = jest.fn(); 194 | const onError = jest.fn(); 195 | 196 | const { result, waitForNextUpdate } = renderHook(() => 197 | useAsync( 198 | // @ts-ignore: not allowed by TS on purpose, but still allowed at runtime 199 | () => fakeResults, 200 | [], 201 | { 202 | onSuccess: () => onSuccess(), 203 | onError: () => onError(), 204 | } 205 | ) 206 | ); 207 | 208 | expect(result.current.loading).toBe(true); 209 | 210 | await waitForNextUpdate(); 211 | 212 | expect(result.current.result).toEqual(fakeResults); 213 | expect(result.current.loading).toBe(false); 214 | expect(result.current.error).toBeUndefined(); 215 | expect(onSuccess).toHaveBeenCalled(); 216 | expect(onError).not.toHaveBeenCalled(); 217 | }); 218 | 219 | it('should set error detail for unsuccessful request', async () => { 220 | const onSuccess = jest.fn(); 221 | const onError = jest.fn(); 222 | 223 | const { result, waitForNextUpdate } = renderHook(() => 224 | useAsync( 225 | async () => { 226 | throw new Error('something went wrong'); 227 | }, 228 | [], 229 | { 230 | onSuccess: () => onSuccess(), 231 | onError: () => onError(), 232 | } 233 | ) 234 | ); 235 | 236 | await waitForNextUpdate(); 237 | 238 | expect(result.current.error).toBeDefined(); 239 | expect(result.current.error!.message).toBe('something went wrong'); 240 | expect(result.current.loading).toBe(false); 241 | expect(result.current.result).toBeUndefined(); 242 | expect(onSuccess).not.toHaveBeenCalled(); 243 | expect(onError).toHaveBeenCalled(); 244 | }); 245 | 246 | it('should set error detail for error thrown synchronously (like when preparing/formatting a payload)', async () => { 247 | const onSuccess = jest.fn(); 248 | const onError = jest.fn(); 249 | 250 | const { result, waitForNextUpdate } = renderHook(() => 251 | useAsync( 252 | () => { 253 | throw new Error('something went wrong'); 254 | }, 255 | [], 256 | { 257 | onSuccess: () => onSuccess(), 258 | onError: () => onError(), 259 | } 260 | ) 261 | ); 262 | 263 | await waitForNextUpdate(); 264 | 265 | expect(result.current.error).toBeDefined(); 266 | expect(result.current.error!.message).toBe('something went wrong'); 267 | expect(result.current.loading).toBe(false); 268 | expect(result.current.result).toBeUndefined(); 269 | expect(onSuccess).not.toHaveBeenCalled(); 270 | expect(onError).toHaveBeenCalled(); 271 | }); 272 | }); 273 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "exclude": ["test"], 4 | "compilerOptions": { 5 | "target": "es5", 6 | "module": "esnext", 7 | "lib": ["dom", "esnext"], 8 | "importHelpers": true, 9 | "declaration": true, 10 | "sourceMap": true, 11 | "rootDir": "./", 12 | "strict": true, 13 | "noImplicitAny": true, 14 | "strictNullChecks": true, 15 | "strictFunctionTypes": true, 16 | "strictPropertyInitialization": true, 17 | "noImplicitThis": true, 18 | "alwaysStrict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noImplicitReturns": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "moduleResolution": "node", 24 | "baseUrl": "./", 25 | "paths": { 26 | "*": ["src/*", "node_modules/*"] 27 | }, 28 | "jsx": "react", 29 | "esModuleInterop": true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "downlevelIteration": true 5 | }, 6 | "include": ["src/**/*.ts", "test/**/*.ts"] 7 | } 8 | --------------------------------------------------------------------------------