import { useState, useEffect, useRef } from 'react'; | |
// Usage | |
function App() { | |
// State and setters for ... | |
// Search term | |
const [searchTerm, setSearchTerm] = useState(''); | |
// API search results | |
const [results, setResults] = useState([]); | |
// Searching status (whether there is pending API request) | |
const [isSearching, setIsSearching] = useState(false); | |
// Debounce search term so that it only gives us latest value ... | |
// ... if searchTerm has not been updated within last 500ms. | |
// The goal is to only have the API call fire when user stops typing ... | |
// ... so that we aren't hitting our API rapidly. | |
const debouncedSearchTerm = useDebounce(searchTerm, 500); | |
// Effect for API call | |
useEffect( | |
() => { | |
if (debouncedSearchTerm) { | |
setIsSearching(true); | |
searchCharacters(debouncedSearchTerm).then(results => { | |
setIsSearching(false); | |
setResults(results); | |
}); | |
} else { | |
setResults([]); | |
} | |
}, | |
[debouncedSearchTerm] // Only call effect if debounced search term changes | |
); | |
return ( | |
<div> | |
<input | |
placeholder="Search Marvel Comics" | |
onChange={e => setSearchTerm(e.target.value)} | |
/> | |
{isSearching && <div>Searching ...</div>} | |
{results.map(result => ( | |
<div key={result.id}> | |
<h4>{result.title}</h4> | |
<img | |
src={`${result.thumbnail.path}/portrait_incredible.${ | |
result.thumbnail.extension | |
}`} | |
/> | |
</div> | |
))} | |
</div> | |
); | |
} | |
// API search function | |
function searchCharacters(search) { | |
const apiKey = 'f9dfb1e8d466d36c27850bedd2047687'; | |
return fetch( | |
`https://gateway.marvel.com/v1/public/comics?apikey=${apiKey}&titleStartsWith=${search}`, | |
{ | |
method: 'GET' | |
} | |
) | |
.then(r => r.json()) | |
.then(r => r.data.results) | |
.catch(error => { | |
console.error(error); | |
return []; | |
}); | |
} | |
// Hook | |
function useDebounce(value, delay) { | |
// State and setters for debounced value | |
const [debouncedValue, setDebouncedValue] = useState(value); | |
useEffect( | |
() => { | |
// Update debounced value after delay | |
const handler = setTimeout(() => { | |
setDebouncedValue(value); | |
}, delay); | |
// Cancel the timeout if value changes (also on delay change or unmount) | |
// This is how we prevent debounced value from updating if value is changed ... | |
// .. within the delay period. Timeout gets cleared and restarted. | |
return () => { | |
clearTimeout(handler); | |
}; | |
}, | |
[value, delay] // Only re-call effect if value or delay changes | |
); | |
return debouncedValue; | |
} |
Appreciate this example. As written, setResults(filteredResults) should be changed to setResults(results.data.results). Also consider using a key in your results.map(). Marvel provides a nice unique result.id you could use.
@AlexGalays @reddhouse All good suggestions, thanks! Updated the code :)
I feel theres a tiny bug here,
// Effect for API call
useEffect(
() => {
if (debouncedSearchTerm) {
setIsSearching(true);
searchCharacters(debouncedSearchTerm).then(results => {
setIsSearching(false);
setResults(results);
});
} else {
setResults([]);
}
},
[debouncedSearchTerm] // Only call effect if debounced search term changes
);
Let's say 2 API calls are made,
Call 1 at time X
Call 2 at time Y, (Y is greater than X)
If Call 2 resolves early and Call 1 resolves late, the latest results in the view will be of Call 1 whereas it should've been of Call 2. Here we need to commit only that result which was latest
.
One way would be to use a ref
,
const latest = React.useRef(null);
...
useEffect(
() => {
if (debouncedSearchTerm) {
let now = latest.current = Date.now()
setIsSearching(true);
searchCharacters(debouncedSearchTerm).then(results => {
if(now === latest.current) {
setIsSearching(false);
setResults(results);
}
});
} else {
setResults([]);
}
},
[debouncedSearchTerm] // Only call effect if debounced search term changes
);
Input's onChange
is triggered by pressing Enter, which obviates the need for a debounce function. Using onInput
makes more sense.
I have tried to replicate the original debounce
function with the immediate
flag here....don't know if this out of the scope for this particular hook or not
import React, { useRef, useEffect, useState } from "react";
import ReactDOM from "react-dom";
import "./styles.css";
/**
* @description should display log message after 1 second
*/
function useDebounce(callback, time, immediate) {
let args;
const id = useRef();
const initialState = useRef();
initialState.current = true;
const debounceFunction = () => {
args = arguments;
};
useEffect(() => {
if (!initialState.current) {
const callNow = immediate && !id.current;
const executeFuncLater = () => {
id.current = null;
if (!immediate) callback(args);
};
if (callNow) callback(args);
id.current = setTimeout(executeFuncLater, time);
}
return () => {
initialState.current = false;
clearTimeout(id.current);
};
});
return debounceFunction;
}
function App() {
const [text, setText] = useState();
const debounceFunction = useDebounce(
() => {
console.log("text", text);
},
500,
true
);
const handleOnClick = () => {
setText(text => text + "test1");
debounceFunction(text);
};
return (
<div className="App">
<button onClick={handleOnClick}>Click Me</button>
<h1>Hello CodeSandbox</h1>
<h2>Start editing to see some magic happen!</h2>
<h2>{text}</h2>
</div>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Lemme know your feedback
PS: Works fine for a click example.....now making it work with the search example in the docs
The current sandbox of useDebounce does not render the image because it is missing an accessibilityLabel (alt): img elements must have an alt prop, either with meaningful text, or an empty string for decorative images. (jsx-a11y/alt-text) eslint This is in Chrome Version 76.0.3809.100.
It also will no longer render because the Marvel API returns a 401 with the following:
{"code":"MissingParameter","message":"You must provide a hash."}
Thanks for the example though!
TypeError
Cannot read property 'results' of undefined
It seems that the setDebouncedValue
function is still called if value
changes since the timer started. The cleanup function does clear the timeout in some scenarios, but that only runs if the containing component is unmounted AFAIK. I was seeing the debouncedValue
state being updated for every value
change, just with the delay. I ended up tweaking useDebounce
a bit and it seems to work. Something like this: (apologize for the typescript if that's not your thing)
const useDebounce = <T>(value: T, delay: number): T => {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
const handlerRef = useRef<NodeJS.Timeout>();
useEffect(() => {
// If there is an active debounce timer, clear it since
// we now have a more up to date value.
if (handlerRef.current) {
clearTimeout(handlerRef.current);
}
handlerRef.current = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => handlerRef.current && clearTimeout(handlerRef.current);
}, [value]);
return debouncedValue;
};
export default useDebounce;
For a reference, here's an alternative implementation at @react-hook/debounce, along with a description why it's supposedly superior: jaredLunde/react-hook#35
No need to do that check
{results &&
as you init the value with an empty array (which is nice)