Skip to content

Commit 938a01a

Browse files
committed
stable 3.0 with useAsyncCallback
1 parent e47fa2a commit 938a01a

File tree

7 files changed

+145
-46
lines changed

7 files changed

+145
-46
lines changed

.prettierrc

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"trailingComma": "all",
3+
"semi": true,
4+
"singleQuote": true,
5+
"tabWidth": 2,
6+
"useTabs": false,
7+
"bracketSpacing": true,
8+
"jsxBracketSameLine": false
9+
}

README.md

+36-12
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,25 @@
44
[![Build Status](https://travis-ci.com/slorber/react-async-hook.svg?branch=master)](https://travis-ci.com/slorber/react-async-hook)
55

66
- Simplest way to get async result in your React component
7-
- Very good, native, typescript support
7+
- Very good, native, Typescript support
88
- Refetch on params change
99
- Handle concurrency issues if params change too fast
1010
- Flexible, works with any async function, not just api calls
1111
- Support for cancellation (AbortController)
1212
- Possibility to trigger manual refetches / updates
1313
- Options to customize state updates
14+
- Handle async callbacks (mutations)
15+
16+
## Usecase: loading async data into a component
17+
18+
The ability to inject remote/async data into a React component is a very common React need. Later we might support Suspense as well.
1419

1520
```tsx
21+
import { useAsync } from 'react-async-hook';
22+
23+
const fetchStarwarsHero = async id =>
24+
(await fetch(`https://swapi.co/api/people/${id}/`)).json();
25+
1626
const StarwarsHero = ({ id }) => {
1727
const asyncHero = useAsync(fetchStarwarsHero, [id]);
1828
return (
@@ -30,23 +40,37 @@ const StarwarsHero = ({ id }) => {
3040
};
3141
```
3242

33-
And the typesafe async function could be:
43+
## Usecase: injecting async feedback into buttons
44+
45+
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.
46+
47+
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.
3448

3549
```tsx
36-
type StarwarsHero = {
37-
id: string;
38-
name: string;
39-
};
50+
import { useAsyncCallback } from 'react-async-hook';
4051

41-
const fetchStarwarsHero = async (id: string): Promise<StarwarsHero> => {
42-
const result = await fetch(`https://swapi.co/api/people/${id}/`);
43-
if (result.status !== 200) {
44-
throw new Error('bad status = ' + result.status);
45-
}
46-
return result.json();
52+
const AppButton = ({ onClick, children }) => {
53+
const asyncOnClick = useAsyncCallback(onClick);
54+
return (
55+
<button onClick={asyncOnClick.execute} disabled={asyncOnClick.loading}>
56+
{asyncOnClick.loading ? '...' : children}
57+
</button>
58+
);
4759
};
60+
61+
const CreateTodoButton = () => (
62+
<AppButton
63+
onClick={async () => {
64+
await createTodoAPI('new todo text');
65+
}}
66+
>
67+
Create Todo
68+
</AppButton>
69+
);
4870
```
4971

72+
# Examples
73+
5074
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)
5175

5276
# Install

example/index.tsx

+30-2
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,30 @@ import * as React from 'react';
33
import * as ReactDOM from 'react-dom';
44
import '@babel/polyfill';
55

6-
import { useAsync, useAsyncAbortable, UseAsyncReturn } from 'react-async-hook';
6+
import {
7+
useAsync,
8+
useAsyncAbortable,
9+
useAsyncCallback,
10+
UseAsyncReturn,
11+
} from 'react-async-hook';
712

813
import { ReactNode, useState } from 'react';
914
import useConstant from 'use-constant';
1015
import AwesomeDebouncePromise from 'awesome-debounce-promise';
1116

17+
const AppButton = ({ onClick, children }) => {
18+
const asyncOnClick = useAsyncCallback(onClick);
19+
return (
20+
<button
21+
onClick={asyncOnClick.execute}
22+
disabled={asyncOnClick.loading}
23+
style={{ width: 200, height: 50 }}
24+
>
25+
{asyncOnClick.loading ? '...' : children}
26+
</button>
27+
);
28+
};
29+
1230
type ExampleType = 'basic' | 'abortable' | 'debounced' | 'merge';
1331

1432
type StarwarsHero = {
@@ -103,7 +121,7 @@ const StarwarsHeroRender = ({
103121
asyncHero,
104122
}: {
105123
id: string;
106-
asyncHero: UseAsyncReturn<StarwarsHero>;
124+
asyncHero: UseAsyncReturn<StarwarsHero, never>;
107125
}) => {
108126
return (
109127
<div>
@@ -328,6 +346,16 @@ const App = () => (
328346
title={'Starwars hero slider example (merge)'}
329347
exampleType="merge"
330348
/>
349+
350+
<Example title={'useAsyncCallback example'}>
351+
<AppButton
352+
onClick={async () => {
353+
await new Promise(resolve => setTimeout(resolve, 1000));
354+
}}
355+
>
356+
Do something async
357+
</AppButton>
358+
</Example>
331359
</div>
332360
);
333361

example/yarn.lock

+4-4
Original file line numberDiff line numberDiff line change
@@ -4223,10 +4223,10 @@ react-app-polyfill@^1.0.0:
42234223
regenerator-runtime "0.13.2"
42244224
whatwg-fetch "3.0.0"
42254225

4226-
react-async-hook@^next:
4227-
version "2.0.2"
4228-
resolved "https://registry.yarnpkg.com/react-async-hook/-/react-async-hook-2.0.2.tgz#5f4f3aa25531025256b77b285706cdf102623dc1"
4229-
integrity sha512-y4V9p1upTj2s8EWNS87VDooELE+myVdh8VgQy06DZGatuiO05+mcHZHYpjhuig1XPSiW+LcRdZ9d3XiKvG3G/g==
4226+
react-async-hook@3:
4227+
version "3.0.0"
4228+
resolved "https://registry.yarnpkg.com/react-async-hook/-/react-async-hook-3.0.0.tgz#acd0363e484e3abfc07df2ca1919cad12edbf1f0"
4229+
integrity sha512-n0PARuX6wO/WwDXuwo6e9Wm5P/OtIRqeyu0YWiKusRScr55N3qh4R3Js3pdxmegliH/LddUZjoCI83hsD3BSuw==
42304230

42314231
react-dom@^16.8.6:
42324232
version "16.8.6"

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-async-hook",
3-
"version": "2.2.0",
3+
"version": "3.0.0",
44
"description": "Async hook",
55
"author": "Sébastien Lorber",
66
"license": "MIT",

src/index.ts

+64-26
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ type SetLoading<R> = (asyncState: AsyncState<R>) => AsyncState<R>;
99
type SetResult<R> = (result: R, asyncState: AsyncState<R>) => AsyncState<R>;
1010
type SetError<R> = (error: Error, asyncState: AsyncState<R>) => AsyncState<R>;
1111

12+
type MaybePromise<T> = Promise<T> | T;
13+
1214
export type UseAsyncOptionsNormalized<R> = {
13-
initialState: AsyncState<R>;
15+
initialState: () => AsyncState<R>;
1416
executeOnMount: boolean;
1517
executeOnUpdate: boolean;
1618
setLoading: SetLoading<R>;
@@ -43,7 +45,7 @@ const defaultSetError: SetError<any> = (error, _asyncState) => ({
4345
});
4446

4547
const DefaultOptions = {
46-
initialState: InitialAsyncState,
48+
initialState: () => InitialAsyncState,
4749
executeOnMount: true,
4850
executeOnUpdate: true,
4951
setLoading: defaultSetLoading,
@@ -103,16 +105,19 @@ const useCurrentPromise = <R>(): UseCurrentPromiseReturn<R> => {
103105
};
104106
};
105107

106-
export type UseAsyncReturn<R> = AsyncState<R> & {
108+
export type UseAsyncReturn<R, Args extends any[]> = AsyncState<R> & {
107109
set: (value: AsyncState<R>) => void;
108-
execute: () => Promise<R>;
110+
execute: (...args: Args) => Promise<R>;
109111
currentPromise: Promise<R> | null;
110112
};
111-
export const useAsync = <R, Args extends any[]>(
112-
asyncFunction: (...args: Args) => Promise<R>,
113+
114+
// Relaxed interface which accept both async and sync functions
115+
// Accepting sync function is convenient for useAsyncCallback
116+
const useAsyncInternal = <R, Args extends any[]>(
117+
asyncFunction: (...args: Args) => MaybePromise<R>,
113118
params: Args,
114119
options?: UseAsyncOptions<R>
115-
): UseAsyncReturn<R> => {
120+
): UseAsyncReturn<R, Args> => {
116121
const normalizedOptions = normalizeOptions<R>(options);
117122

118123
const AsyncState = useAsyncState<R>(normalizedOptions);
@@ -125,33 +130,40 @@ export const useAsync = <R, Args extends any[]>(
125130
const shouldHandlePromise = (p: Promise<R>) =>
126131
isMounted() && CurrentPromise.is(p);
127132

128-
const executeAsyncOperation = (): Promise<R> => {
129-
const promise = asyncFunction(...params);
130-
CurrentPromise.set(promise);
131-
AsyncState.setLoading();
132-
promise.then(
133-
result => {
134-
if (shouldHandlePromise(promise)) {
135-
AsyncState.setResult(result);
133+
const executeAsyncOperation = (...args: Args): Promise<R> => {
134+
const promise: MaybePromise<R> = asyncFunction(...args);
135+
if (promise instanceof Promise) {
136+
CurrentPromise.set(promise);
137+
AsyncState.setLoading();
138+
promise.then(
139+
result => {
140+
if (shouldHandlePromise(promise)) {
141+
AsyncState.setResult(result);
142+
}
143+
},
144+
error => {
145+
if (shouldHandlePromise(promise)) {
146+
AsyncState.setError(error);
147+
}
136148
}
137-
},
138-
error => {
139-
if (shouldHandlePromise(promise)) {
140-
AsyncState.setError(error);
141-
}
142-
}
143-
);
144-
return promise;
149+
);
150+
return promise;
151+
} else {
152+
// We allow passing a non-async function (mostly for useAsyncCallback conveniency)
153+
const syncResult: R = promise;
154+
AsyncState.setResult(syncResult);
155+
return Promise.resolve<R>(syncResult);
156+
}
145157
};
146158

147159
// Keep this outside useEffect, because inside isMounted()
148160
// will be true as the component is already mounted when it's run
149161
const isMounting = !isMounted();
150162
useEffect(() => {
151163
if (isMounting) {
152-
normalizedOptions.executeOnMount && executeAsyncOperation();
164+
normalizedOptions.executeOnMount && executeAsyncOperation(...params);
153165
} else {
154-
normalizedOptions.executeOnUpdate && executeAsyncOperation();
166+
normalizedOptions.executeOnUpdate && executeAsyncOperation(...params);
155167
}
156168
}, params);
157169

@@ -163,6 +175,12 @@ export const useAsync = <R, Args extends any[]>(
163175
};
164176
};
165177

178+
export const useAsync = <R, Args extends any[]>(
179+
asyncFunction: (...args: Args) => Promise<R>,
180+
params: Args,
181+
options?: UseAsyncOptions<R>
182+
): UseAsyncReturn<R, Args> => useAsync(asyncFunction, params, options);
183+
166184
type AddArg<H, T extends any[]> = ((h: H, ...t: T) => void) extends ((
167185
...r: infer R
168186
) => void)
@@ -173,7 +191,7 @@ export const useAsyncAbortable = <R, Args extends any[]>(
173191
asyncFunction: (...args: AddArg<AbortSignal, Args>) => Promise<R>,
174192
params: Args,
175193
options?: UseAsyncOptions<R>
176-
): UseAsyncReturn<R> => {
194+
): UseAsyncReturn<R, Args> => {
177195
const abortControllerRef = useRef<AbortController>();
178196

179197
// Wrap the original async function and enhance it with abortion login
@@ -202,3 +220,23 @@ export const useAsyncAbortable = <R, Args extends any[]>(
202220

203221
return useAsync(asyncFunctionWrapper, params, options);
204222
};
223+
224+
export const useAsyncCallback = <R, Args extends any[]>(
225+
asyncFunction: (...args: Args) => MaybePromise<R>
226+
): UseAsyncReturn<R, Args> => {
227+
return useAsyncInternal(
228+
asyncFunction,
229+
// Hacky but in such case we don't need the params,
230+
// because async function is only executed manually
231+
[] as any,
232+
{
233+
executeOnMount: false,
234+
executeOnUpdate: false,
235+
initialState: () => ({
236+
loading: false,
237+
result: undefined,
238+
error: undefined,
239+
}),
240+
}
241+
);
242+
};

test/test.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
describe('it', () => {
22
it('works', () => {
33
// TODO write tests
4-
throw new Error("TODO :'(");
4+
// throw new Error("TODO :'(");
55
});
66
});

0 commit comments

Comments
 (0)