Skip to content

Commit 4cb214d

Browse files
committed
Add example for debouncing a search input
1 parent 7ebb402 commit 4cb214d

File tree

2 files changed

+225
-39
lines changed

2 files changed

+225
-39
lines changed

README.md

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,90 @@ const StarwarsHero = ({ id }) => {
8484
};
8585
```
8686

87-
#### How to use request cancellation
87+
#### How can I implement a debounced search input / autocomplete?
88+
89+
This is one of the most common usecase for fetching data + debouncing in a component, and can be implemented easily by composing different libraries.
90+
All this logic can easily be extracted into a single hook that you can reuse. Here is an example:
91+
92+
```tsx
93+
const searchStarwarsHero = async (
94+
text: string,
95+
abortSignal?: AbortSignal
96+
): Promise<StarwarsHero[]> => {
97+
const result = await fetch(
98+
`https://swapi.co/api/people/?search=${encodeURIComponent(text)}`,
99+
{
100+
signal: abortSignal,
101+
}
102+
);
103+
if (result.status !== 200) {
104+
throw new Error('bad status = ' + result.status);
105+
}
106+
const json = await result.json();
107+
return json.results;
108+
};
109+
110+
const useSearchStarwarsHero = () => {
111+
// Handle the input text state
112+
const [inputText, setInputText] = useState('');
113+
114+
// Debounce the original search async function
115+
const debouncedSearchStarwarsHero = useConstant(() =>
116+
AwesomeDebouncePromise(searchStarwarsHero, 300)
117+
);
118+
119+
const search = useAsyncAbortable(
120+
async (abortSignal, text) => {
121+
// If the input is empty, return nothing immediately (without the debouncing delay!)
122+
if (text.length === 0) {
123+
return [];
124+
}
125+
// Else we use the debounced api
126+
else {
127+
return debouncedSearchStarwarsHero(text, abortSignal);
128+
}
129+
},
130+
// Ensure a new request is made everytime the text changes (even if it's debounced)
131+
[inputText]
132+
);
133+
134+
// Return everything needed for the hook consumer
135+
return {
136+
inputText,
137+
setInputText,
138+
search,
139+
};
140+
};
141+
```
142+
143+
And then you can use your hook easily:
144+
145+
```tsx
146+
const SearchStarwarsHeroExample = () => {
147+
const { inputText, setInputText, search } = useSearchStarwarsHero();
148+
return (
149+
<div>
150+
<input value={inputText} onChange={e => setInputText(e.target.value)} />
151+
<div>
152+
{search.loading && <div>...</div>}
153+
{search.error && <div>Error: {search.error.message}</div>}
154+
{search.result && (
155+
<div>
156+
<div>Results: {search.result.length}</div>
157+
<ul>
158+
{search.result.map(hero => (
159+
<li key={hero.name}>{hero.name}</li>
160+
))}
161+
</ul>
162+
</div>
163+
)}
164+
</div>
165+
</div>
166+
);
167+
};
168+
```
169+
170+
#### How to use request cancellation?
88171

89172
You can use the `useAsyncAbortable` alternative. The async function provided will receive `(abortSignal, ...params)` .
90173

@@ -110,7 +193,7 @@ const StarwarsHero = ({ id }) => {
110193
};
111194
```
112195

113-
#### How can I keep previous results available while a new request is pending
196+
#### How can I keep previous results available while a new request is pending?
114197

115198
It can be annoying to have the previous async call result be "erased" everytime a new call is triggered (default strategy).
116199
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.
@@ -126,7 +209,7 @@ const StarwarsHero = ({ id }) => {
126209
};
127210
```
128211

129-
#### How to refresh / refetch the data
212+
#### How to refresh / refetch the data?
130213

131214
If your params are not changing, yet you need to refresh the data, you can call `execute()`
132215

@@ -138,7 +221,7 @@ const StarwarsHero = ({ id }) => {
138221
};
139222
```
140223

141-
#### How to support retry
224+
#### How to support retry?
142225

143226
Use a lib that simply adds retry feature to async/promises directly. Doesn't exist? Build it.
144227

example/index.tsx

Lines changed: 138 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import '@babel/polyfill';
55

66
import { useAsync, useAsyncAbortable, UseAsyncReturn } from 'react-async-hook';
77

8-
import { useState } from 'react';
8+
import { ReactNode, useState } from 'react';
99
import useConstant from 'use-constant';
1010
import AwesomeDebouncePromise from 'awesome-debounce-promise';
1111

@@ -29,6 +29,23 @@ const fetchStarwarsHero = async (
2929
return result.json();
3030
};
3131

32+
const searchStarwarsHero = async (
33+
text: string,
34+
abortSignal?: AbortSignal
35+
): Promise<StarwarsHero[]> => {
36+
const result = await fetch(
37+
`https://swapi.co/api/people/?search=${encodeURIComponent(text)}`,
38+
{
39+
signal: abortSignal,
40+
}
41+
);
42+
if (result.status !== 200) {
43+
throw new Error('bad status = ' + result.status);
44+
}
45+
const json = await result.json();
46+
return json.results;
47+
};
48+
3249
const HeroContainer = ({ children }) => (
3350
<div
3451
style={{
@@ -47,6 +64,40 @@ const HeroContainer = ({ children }) => (
4764
</div>
4865
);
4966

67+
const Example = ({
68+
title,
69+
children,
70+
}: {
71+
title: string;
72+
children: ReactNode;
73+
}) => (
74+
<div
75+
style={{
76+
margin: 20,
77+
padding: 20,
78+
border: 'solid',
79+
}}
80+
>
81+
<h1
82+
style={{
83+
marginBottom: 10,
84+
paddingBottom: 10,
85+
borderBottom: 'solid thin red',
86+
}}
87+
>
88+
{title}
89+
</h1>
90+
<div
91+
style={{
92+
marginBottom: 10,
93+
paddingBottom: 10,
94+
}}
95+
>
96+
{children}
97+
</div>
98+
</div>
99+
);
100+
50101
const StarwarsHeroRender = ({
51102
id,
52103
asyncHero,
@@ -117,15 +168,7 @@ const StarwarsHeroLoader = ({
117168
}
118169
};
119170

120-
const buttonStyle = {
121-
border: 'solid',
122-
cursor: 'pointer',
123-
borderRadius: 50,
124-
padding: 10,
125-
margin: 10,
126-
};
127-
128-
const StarwarsExample = ({
171+
const StarwarsSliderExample = ({
129172
title,
130173
exampleType,
131174
}: {
@@ -135,23 +178,16 @@ const StarwarsExample = ({
135178
const [heroId, setHeroId] = useState(1);
136179
const next = () => setHeroId(heroId + 1);
137180
const previous = () => setHeroId(heroId - 1);
181+
182+
const buttonStyle = {
183+
border: 'solid',
184+
cursor: 'pointer',
185+
borderRadius: 50,
186+
padding: 10,
187+
margin: 10,
188+
};
138189
return (
139-
<div
140-
style={{
141-
margin: 20,
142-
padding: 20,
143-
border: 'solid',
144-
}}
145-
>
146-
<h1
147-
style={{
148-
marginBottom: 10,
149-
paddingBottom: 10,
150-
border: 'solid',
151-
}}
152-
>
153-
{title}
154-
</h1>
190+
<Example title={title}>
155191
<div style={{ display: 'flex' }}>
156192
<div style={buttonStyle} onClick={previous}>
157193
Previous
@@ -172,7 +208,73 @@ const StarwarsExample = ({
172208
<StarwarsHeroLoader id={`${heroId + 2}`} exampleType={exampleType} />
173209
</HeroContainer>
174210
</div>
175-
</div>
211+
</Example>
212+
);
213+
};
214+
215+
const useSearchStarwarsHero = () => {
216+
// Handle the input text state
217+
const [inputText, setInputText] = useState('');
218+
219+
// Debounce the original search async function
220+
const debouncedSearchStarwarsHero = useConstant(() =>
221+
AwesomeDebouncePromise(searchStarwarsHero, 300)
222+
);
223+
224+
const search = useAsyncAbortable(
225+
async (abortSignal, text) => {
226+
// If the input is empty, return nothing immediately (without the debouncing delay!)
227+
if (text.length === 0) {
228+
return [];
229+
}
230+
// Else we use the debounced api
231+
else {
232+
return debouncedSearchStarwarsHero(text, abortSignal);
233+
}
234+
},
235+
// Ensure a new request is made everytime the text changes (even if it's debounced)
236+
[inputText]
237+
);
238+
239+
// Return everything needed for the hook consumer
240+
return {
241+
inputText,
242+
setInputText,
243+
search,
244+
};
245+
};
246+
247+
const SearchStarwarsHeroExample = () => {
248+
const { inputText, setInputText, search } = useSearchStarwarsHero();
249+
return (
250+
<Example title={'Search starwars hero'}>
251+
<input
252+
value={inputText}
253+
onChange={e => setInputText(e.target.value)}
254+
placeholder="Search starwars hero"
255+
style={{
256+
marginTop: 20,
257+
padding: 10,
258+
border: 'solid thin',
259+
borderRadius: 5,
260+
width: 300,
261+
}}
262+
/>
263+
<div style={{ marginTop: 20 }}>
264+
{search.loading && <div>...</div>}
265+
{search.error && <div>Error: {search.error.message}</div>}
266+
{search.result && (
267+
<div>
268+
<div>Results: {search.result.length}</div>
269+
<ul>
270+
{search.result.map(hero => (
271+
<li key={hero.name}>{hero.name}</li>
272+
))}
273+
</ul>
274+
</div>
275+
)}
276+
</div>
277+
</Example>
176278
);
177279
};
178280

@@ -209,20 +311,21 @@ const App = () => (
209311
</h2>
210312
</div>
211313

212-
<StarwarsExample
213-
title={'Starwars hero example (basic)'}
314+
<SearchStarwarsHeroExample />
315+
<StarwarsSliderExample
316+
title={'Starwars hero slider example (basic)'}
214317
exampleType="basic"
215318
/>
216-
<StarwarsExample
217-
title={'Starwars hero example (debounced)'}
319+
<StarwarsSliderExample
320+
title={'Starwars hero slider example (debounced)'}
218321
exampleType="debounced"
219322
/>
220-
<StarwarsExample
221-
title={'Starwars hero example (abortable)'}
323+
<StarwarsSliderExample
324+
title={'Starwars hero slider example (abortable)'}
222325
exampleType="abortable"
223326
/>
224-
<StarwarsExample
225-
title={'Starwars hero example (merge)'}
327+
<StarwarsSliderExample
328+
title={'Starwars hero slider example (merge)'}
226329
exampleType="merge"
227330
/>
228331
</div>

0 commit comments

Comments
 (0)