2019: try hooks + promise debouncing
This is the most up to date version of how I would solve this problem. I would use:
This is some initial wiring but you are composing primitive blocks on your own, and you can make your own custom hook so that you only need to do this once.
// Generic reusable hook
const useDebouncedSearch = (searchFunction) => {
// Handle the input text state
const [inputText, setInputText] = useState('');
// Debounce the original search async function
const debouncedSearchFunction = useConstant(() =>
AwesomeDebouncePromise(searchFunction, 300)
);
// The async callback is run each time the text changes,
// but as the search function is debounced, it does not
// fire a new request on each keystroke
const searchResults = useAsync(
async () => {
if (inputText.length === 0) {
return [];
} else {
return debouncedSearchFunction(inputText);
}
},
[debouncedSearchFunction, inputText]
);
// Return everything needed for the hook consumer
return {
inputText,
setInputText,
searchResults,
};
};
And then you can use your hook:
const useSearchStarwarsHero = () => useDebouncedSearch(text => searchStarwarsHeroAsync(text))
const SearchStarwarsHeroExample = () => {
const { inputText, setInputText, searchResults } = useSearchStarwarsHero();
return (
<div>
<input value={inputText} onChange={e => setInputText(e.target.value)} />
<div>
{searchResults.loading && <div>...</div>}
{searchResults.error && <div>Error: {search.error.message}</div>}
{searchResults.result && (
<div>
<div>Results: {search.result.length}</div>
<ul>
{searchResults.result.map(hero => (
<li key={hero.name}>{hero.name}</li>
))}
</ul>
</div>
)}
</div>
</div>
);
};
You will find this example running here and you should read react-async-hook documentation for more details.
2018: try promise debouncing
We often want to debounce API calls to avoid flooding the backend with useless requests.
In 2018, working with callbacks (Lodash/Underscore) feels bad and error-prone to me. It's easy to encounter boilerplate and concurrency issues due to API calls resolving in an arbitrary order.
I've created a little library with React in mind to solve your pains: awesome-debounce-promise.
This should not be more complicated than that:
const searchAPI = text => fetch('/search?text=' + encodeURIComponent(text));
const searchAPIDebounced = AwesomeDebouncePromise(searchAPI, 500);
class SearchInputAndResults extends React.Component {
state = {
text: '',
results: null,
};
handleTextChange = async text => {
this.setState({ text, results: null });
const result = await searchAPIDebounced(text);
this.setState({ result });
};
}
The debounced function ensures that:
- API calls will be debounced
- the debounced function always returns a promise
- only the last call's returned promise will resolve
- a single
this.setState({ result });
will happen per API call
Eventually, you may add another trick if your component unmounts:
componentWillUnmount() {
this.setState = () => {};
}
Note that Observables (RxJS) can also be a great fit for debouncing inputs, but it's a more powerful abstraction which may be harder to learn/use correctly.
< 2017: still want to use callback debouncing?
The important part here is to create a single debounced (or throttled) function per component instance. You don't want to recreate the debounce (or throttle) function everytime, and you don't want either multiple instances to share the same debounced function.
I'm not defining a debouncing function in this answer as it's not really relevant, but this answer will work perfectly fine with _.debounce
of underscore or lodash, as well as any user-provided debouncing function.
GOOD IDEA:
Because debounced functions are stateful, we have to create one debounced function per component instance.
ES6 (class property): recommended
class SearchBox extends React.Component {
method = debounce(() => {
...
});
}
ES6 (class constructor)
class SearchBox extends React.Component {
constructor(props) {
super(props);
this.method = debounce(this.method.bind(this),1000);
}
method() { ... }
}
ES5
var SearchBox = React.createClass({
method: function() {...},
componentWillMount: function() {
this.method = debounce(this.method.bind(this),100);
},
});
See JsFiddle: 3 instances are producing 1 log entry per instance (that makes 3 globally).
NOT a good idea:
var SearchBox = React.createClass({
method: function() {...},
debouncedMethod: debounce(this.method, 100);
});
It won't work, because during class description object creation, this
is not the object created itself. this.method
does not return what you expect because the this
context is not the object itself (which actually does not really exist yet BTW as it is just being created).
NOT a good idea:
var SearchBox = React.createClass({
method: function() {...},
debouncedMethod: function() {
var debounced = debounce(this.method,100);
debounced();
},
});
This time you are effectively creating a debounced function that calls your this.method
. The problem is that you are recreating it on every debouncedMethod
call, so the newly created debounce function does not know anything about former calls! You must reuse the same debounced function over time or the debouncing will not happen.
NOT a good idea:
var SearchBox = React.createClass({
debouncedMethod: debounce(function () {...},100),
});
This is a little bit tricky here.
All the mounted instances of the class will share the same debounced function, and most often this is not what you want!. See JsFiddle: 3 instances are producting only 1 log entry globally.
You have to create a debounced function for each component instance, and not a single debounced function at the class level, shared by each component instance.
Take care of React's event pooling
This is related because we often want to debounce or throttle DOM events.
In React, the event objects (i.e., SyntheticEvent
) that you receive in callbacks are pooled (this is now documented). This means that after the event callback has be called, the SyntheticEvent you receive will be put back in the pool with empty attributes to reduce the GC pressure.
So if you access SyntheticEvent
properties asynchronously to the original callback (as may be the case if you throttle/debounce), the properties you access may be erased. If you want the event to never be put back in the pool, you can use the persist()
method.
Without persist (default behavior: pooled event)
onClick = e => {
alert(`sync -> hasNativeEvent=${!!e.nativeEvent}`);
setTimeout(() => {
alert(`async -> hasNativeEvent=${!!e.nativeEvent}`);
}, 0);
};
The 2nd (async) will print hasNativeEvent=false
because the event properties have been cleaned up.
With persist
onClick = e => {
e.persist();
alert(`sync -> hasNativeEvent=${!!e.nativeEvent}`);
setTimeout(() => {
alert(`async -> hasNativeEvent=${!!e.nativeEvent}`);
}, 0);
};
The 2nd (async) will print hasNativeEvent=true
because persist
allows you to avoid putting the event back in the pool.
You can test these 2 behaviors here: JsFiddle
Read Julen's answer for an example of using persist()
with a throttle/debounce function.