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 library only **does one small thing**, and **does it well**.
|
7 |
|
8 | Don't expect it to grow in size, because it is **feature complete**:
|
9 |
|
10 | - Handle fetches (`useAsync`)
|
11 | - Handle mutations (`useAsyncCallback`)
|
12 | - Handle cancellation (`useAsyncAbortable` + `AbortController`)
|
13 | - Handle race conditions
|
14 | - Platform agnostic
|
15 | - Works with any async function, not just backend API calls, not just fetch/axios...
|
16 | - Very good, native, Typescript support
|
17 | - Small, no dependency
|
18 | - Rules of hooks: ESLint find missing dependencies
|
19 | - Refetch on params change
|
20 | - Can trigger manual refetch
|
21 | - Options to customize state updates
|
22 | - Can mutate state after fetch
|
23 |
|
24 | ## Small size
|
25 |
|
26 | - Tiny (1.5k minified gzipped)
|
27 | - At least 3 times smaller than popular alternatives.
|
28 | - CommonJS + ESM bundles, tree-shakable
|
29 | - Design using composition, good tree-shakeability
|
30 |
|
31 | **react-async-hook**:
|
32 |
|
33 |
|
34 | - ![](https://img.shields.io/bundlephobia/min/react-async-hook.svg)
|
35 | - ![](https://img.shields.io/bundlephobia/minzip/react-async-hook.svg)
|
36 |
|
37 |
|
38 | **React-Query**:
|
39 |
|
40 | - ![](https://img.shields.io/bundlephobia/min/react-query.svg)
|
41 | - ![](https://img.shields.io/bundlephobia/minzip/react-query.svg)
|
42 |
|
43 |
|
44 | **SWR**:
|
45 |
|
46 | - ![](https://img.shields.io/bundlephobia/min/swr.svg)
|
47 | - ![](https://img.shields.io/bundlephobia/minzip/swr.svg)
|
48 |
|
49 |
|
50 | ## Things we don't support (by design):
|
51 |
|
52 | - stale-while-revalidate
|
53 | - refetch on focus / resume
|
54 | - caching
|
55 | - polling
|
56 | - request deduplication
|
57 | - platform-specific code
|
58 | - scroll position restoration
|
59 | - SSR
|
60 | - router integration for render-as-you-fetch pattern
|
61 |
|
62 | You can indeed build on top of this little lib to provide more advanced features, if you like composition, that is encouraged in the React ecosystem.
|
63 |
|
64 | If you prefer a full-featured fetching library, try [SWR](https://github.com/vercel/swr) or [React-Query](https://github.com/tannerlinsley/react-query).
|
65 |
|
66 |
|
67 | ## Usecase: loading async data into a component
|
68 |
|
69 | The ability to inject remote/async data into a React component is a very common React need. Later we might support Suspense as well.
|
70 |
|
71 | ```tsx
|
72 | import { useAsync } from 'react-async-hook';
|
73 |
|
74 | const fetchStarwarsHero = async id =>
|
75 | (await fetch(`https://swapi.co/api/people/${id}/`)).json();
|
76 |
|
77 | const StarwarsHero = ({ id }) => {
|
78 | const asyncHero = useAsync(fetchStarwarsHero, [id]);
|
79 | return (
|
80 | <div>
|
81 | {asyncHero.loading && <div>Loading</div>}
|
82 | {asyncHero.error && <div>Error: {asyncHero.error.message}</div>}
|
83 | {asyncHero.result && (
|
84 | <div>
|
85 | <div>Success!</div>
|
86 | <div>Name: {asyncHero.result.name}</div>
|
87 | </div>
|
88 | )}
|
89 | </div>
|
90 | );
|
91 | };
|
92 | ```
|
93 |
|
94 | ## Usecase: injecting async feedback into buttons
|
95 |
|
96 | 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.
|
97 |
|
98 | 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.
|
99 |
|
100 | ```tsx
|
101 | import { useAsyncCallback } from 'react-async-hook';
|
102 |
|
103 | const AppButton = ({ onClick, children }) => {
|
104 | const asyncOnClick = useAsyncCallback(onClick);
|
105 | return (
|
106 | <button onClick={asyncOnClick.execute} disabled={asyncOnClick.loading}>
|
107 | {asyncOnClick.loading ? '...' : children}
|
108 | </button>
|
109 | );
|
110 | };
|
111 |
|
112 | const CreateTodoButton = () => (
|
113 | <AppButton
|
114 | onClick={async () => {
|
115 | await createTodoAPI('new todo text');
|
116 | }}
|
117 | >
|
118 | Create Todo
|
119 | </AppButton>
|
120 | );
|
121 | ```
|
122 |
|
123 | # Examples
|
124 |
|
125 | 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)
|
126 |
|
127 | # Install
|
128 |
|
129 | `yarn add react-async-hook`
|
130 | or
|
131 |
|
132 | `npm install react-async-hook --save`
|
133 |
|
134 | ## ESLint
|
135 |
|
136 | 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:
|
137 |
|
138 | ```ts
|
139 | // .eslintrc.js
|
140 | module.exports = {
|
141 | // ...
|
142 | rules: {
|
143 | 'react-hooks/rules-of-hooks': 'error',
|
144 | 'react-hooks/exhaustive-deps': [
|
145 | 'error',
|
146 | {
|
147 | additionalHooks: '(useAsync|useAsyncCallback)',
|
148 | },
|
149 | ],
|
150 | }
|
151 | }
|
152 | ```
|
153 |
|
154 |
|
155 | # FAQ
|
156 |
|
157 | #### How can I debounce the request
|
158 |
|
159 | It is possible to debounce a promise.
|
160 |
|
161 | 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 usecase of a debounced search input/autocomplete)
|
162 |
|
163 | 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`).
|
164 |
|
165 | ```tsx
|
166 | const StarwarsHero = ({ id }) => {
|
167 | // Create a constant debounced function (created only once per component instance)
|
168 | const debouncedFetchStarwarsHero = useConstant(() =>
|
169 | AwesomeDebouncePromise(fetchStarwarsHero, 1000)
|
170 | );
|
171 |
|
172 | // Simply use it with useAsync
|
173 | const asyncHero = useAsync(debouncedFetchStarwarsHero, [id]);
|
174 |
|
175 | return <div>...</div>;
|
176 | };
|
177 | ```
|
178 |
|
179 | #### How can I implement a debounced search input / autocomplete?
|
180 |
|
181 | This is one of the most common usecase for fetching data + debouncing in a component, and can be implemented easily by composing different libraries.
|
182 | All this logic can easily be extracted into a single hook that you can reuse. Here is an example:
|
183 |
|
184 | ```tsx
|
185 | const searchStarwarsHero = async (
|
186 | text: string,
|
187 | abortSignal?: AbortSignal
|
188 | ): Promise<StarwarsHero[]> => {
|
189 | const result = await fetch(
|
190 | `https://swapi.co/api/people/?search=${encodeURIComponent(text)}`,
|
191 | {
|
192 | signal: abortSignal,
|
193 | }
|
194 | );
|
195 | if (result.status !== 200) {
|
196 | throw new Error('bad status = ' + result.status);
|
197 | }
|
198 | const json = await result.json();
|
199 | return json.results;
|
200 | };
|
201 |
|
202 | const useSearchStarwarsHero = () => {
|
203 | // Handle the input text state
|
204 | const [inputText, setInputText] = useState('');
|
205 |
|
206 | // Debounce the original search async function
|
207 | const debouncedSearchStarwarsHero = useConstant(() =>
|
208 | AwesomeDebouncePromise(searchStarwarsHero, 300)
|
209 | );
|
210 |
|
211 | const search = useAsyncAbortable(
|
212 | async (abortSignal, text) => {
|
213 | // If the input is empty, return nothing immediately (without the debouncing delay!)
|
214 | if (text.length === 0) {
|
215 | return [];
|
216 | }
|
217 | // Else we use the debounced api
|
218 | else {
|
219 | return debouncedSearchStarwarsHero(text, abortSignal);
|
220 | }
|
221 | },
|
222 | // Ensure a new request is made everytime the text changes (even if it's debounced)
|
223 | [inputText]
|
224 | );
|
225 |
|
226 | // Return everything needed for the hook consumer
|
227 | return {
|
228 | inputText,
|
229 | setInputText,
|
230 | search,
|
231 | };
|
232 | };
|
233 | ```
|
234 |
|
235 | And then you can use your hook easily:
|
236 |
|
237 | ```tsx
|
238 | const SearchStarwarsHeroExample = () => {
|
239 | const { inputText, setInputText, search } = useSearchStarwarsHero();
|
240 | return (
|
241 | <div>
|
242 | <input value={inputText} onChange={e => setInputText(e.target.value)} />
|
243 | <div>
|
244 | {search.loading && <div>...</div>}
|
245 | {search.error && <div>Error: {search.error.message}</div>}
|
246 | {search.result && (
|
247 | <div>
|
248 | <div>Results: {search.result.length}</div>
|
249 | <ul>
|
250 | {search.result.map(hero => (
|
251 | <li key={hero.name}>{hero.name}</li>
|
252 | ))}
|
253 | </ul>
|
254 | </div>
|
255 | )}
|
256 | </div>
|
257 | </div>
|
258 | );
|
259 | };
|
260 | ```
|
261 |
|
262 | #### How to use request cancellation?
|
263 |
|
264 | You can use the `useAsyncAbortable` alternative. The async function provided will receive `(abortSignal, ...params)` .
|
265 |
|
266 | 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.
|
267 | It is your responsability to wire the abort signal appropriately.
|
268 |
|
269 | ```tsx
|
270 | const StarwarsHero = ({ id }) => {
|
271 | const asyncHero = useAsyncAbortable(
|
272 | async (abortSignal, id) => {
|
273 | const result = await fetch(`https://swapi.co/api/people/${id}/`, {
|
274 | signal: abortSignal,
|
275 | });
|
276 | if (result.status !== 200) {
|
277 | throw new Error('bad status = ' + result.status);
|
278 | }
|
279 | return result.json();
|
280 | },
|
281 | [id]
|
282 | );
|
283 |
|
284 | return <div>...</div>;
|
285 | };
|
286 | ```
|
287 |
|
288 | #### How can I keep previous results available while a new request is pending?
|
289 |
|
290 | It can be annoying to have the previous async call result be "erased" everytime a new call is triggered (default strategy).
|
291 | 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.
|
292 | It is possible to provide your own "merge" strategies.
|
293 | The following will ensure that on new calls, the previous result is kept until the new call result is received
|
294 |
|
295 | ```tsx
|
296 | const StarwarsHero = ({ id }) => {
|
297 | const asyncHero = useAsync(fetchStarwarsHero, [id], {
|
298 | setLoading: state => ({ ...state, loading: true }),
|
299 | });
|
300 | return <div>...</div>;
|
301 | };
|
302 | ```
|
303 |
|
304 | #### How to refresh / refetch the data?
|
305 |
|
306 | If your params are not changing, yet you need to refresh the data, you can call `execute()`
|
307 |
|
308 | ```tsx
|
309 | const StarwarsHero = ({ id }) => {
|
310 | const asyncHero = useAsync(fetchStarwarsHero, [id]);
|
311 |
|
312 | return <div onClick={() => asyncHero.execute()}>...</div>;
|
313 | };
|
314 | ```
|
315 |
|
316 | #### How to have better control when things get fetched/refetched?
|
317 |
|
318 | 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.
|
319 |
|
320 | In this case you'd better use a closure with no arg define in the dependency array which params should trigger a refetch:
|
321 |
|
322 | Here, both `state.a` and `state.b` will trigger a refetch, despite b is not passed to the async fetch function.
|
323 |
|
324 | ```tsx
|
325 | const asyncSomething = useAsync(() => fetchSomething(state.a), [state.a,state.b]);
|
326 | ```
|
327 |
|
328 | Here, only `state.a` will trigger a refetch, despite b being passed to the async fetch function.
|
329 |
|
330 | ```tsx
|
331 | const asyncSomething = useAsync(() => fetchSomething(state.a, state.b), [state.a]);
|
332 | ```
|
333 |
|
334 | 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:
|
335 |
|
336 | ```tsx
|
337 | const asyncSomething = useAsync(async () => {
|
338 | const payload = buildFetchPayload(state);
|
339 | const result = await fetchSomething(payload);
|
340 | return result;
|
341 | }), [state.a, state.b, state.whateverNeedToTriggerRefetch]);
|
342 | ```
|
343 |
|
344 | You can also use `useAsyncCallback` to decide yourself manually when a fetch should be done:
|
345 |
|
346 | ```tsx
|
347 | const asyncSomething = useAsyncCallback(async () => {
|
348 | const payload = buildFetchPayload(state);
|
349 | const result = await fetchSomething(payload);
|
350 | return result;
|
351 | }));
|
352 |
|
353 | // Call this manually whenever you need:
|
354 | asyncSomething.execute();
|
355 | ```
|
356 |
|
357 |
|
358 | #### How to support retry?
|
359 |
|
360 | Use a lib that simply adds retry feature to async/promises directly. Doesn't exist? Build it.
|
361 |
|
362 | # License
|
363 |
|
364 | MIT
|
365 |
|
366 | # Hire a freelance expert
|
367 |
|
368 | Looking for a React/ReactNative freelance expert with more than 5 years production experience?
|
369 | Contact me from my [website](https://sebastienlorber.com/) or with [Twitter](https://twitter.com/sebastienlorber).
|