5 steps to perform a search when user stops typing using React + Hooks in a controlled component
I need to perform a search when the user stops typing. But with React I can't find how it works!
It's a common use-case to want to perform a search after the user stops typing. This way the user doesn't have to manually click on a button to get feedback from his actions. Despite being a simple use-case to understand, it can present some challenges when trying to implement it with React.
To illustrate the problem and the solution, I'll use a simple React app that makes requests to the Hacker News API based on the values of an input field. For this solution, we'll be using a controlled input and useEffect
hook.
As a starting point, we might have something like the code below:
import React, { useState, useEffect, useReducer } from 'react';
import axios from 'axios';
function fetchReducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return {
...state,
isLoading: true,
hasError: false,
};
case 'FETCH_SUCCESS':
return {
...state,
isLoading: false,
hasError: false,
hits: action.payload,
};
case 'FETCH_FAILURE':
return {
...state,
isLoading: false,
hasError: true,
};
default:
throw new Error();
}
}
async function fetchHits(query, dispatch) {
dispatch({ type: 'FETCH_START' });
try {
const result = await axios(
`https://hn.algolia.com/api/v1/search?query=${query}`
);
dispatch({ type: 'FETCH_SUCCESS', payload: result.data.hits });
} catch (err) {
dispatch({ type: 'FETCH_FAILURE' });
}
}
function App() {
const [{ hits, hasError, isLoading }, dispatch] = useReducer(fetchReducer, {
hits: [],
isLoading: true,
hasError: false,
});
const [query, setQuery] = useState('react');
useEffect(() => {
fetchHits(query, dispatch);
}, [query]);
return (
<>
<input
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
{hasError && <div>Something went wrong ...</div>}
{isLoading ? (
<div>Loading ...</div>
) : (
<ul>
{hits.map((item) => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
)}
</>
);
}
export default App;
1. Apply debounce
Since we're using the onChange
event handler to trigger calls to setQuery
, and given query
is a useEffect
dependency, for every character the user changes on the input the process of fetching data will be started. As we only want to start fetching data some time after the user has stopped typing, we are going to debounce fetchHits()
.
Given that correctly writing a debounce function can be a hard task, we'll be using the debounce()
function from Lodash.
A first try might look like this:
function App() {
const [{ hits, hasError, isLoading }, dispatch] = useReducer(fetchReducer, {
hits: [],
isLoading: true,
hasError: false,
});
const [query, setQuery] = useState('react');
useEffect(() => {
_.debounce(() => fetchHits(query, dispatch), 500)();
}, [query]);
return <>...</>;
}
If you run the new version of the code, you'll notice that although there is a delay between the user typing and fetchHits()
being called, there's still going to be one call every time the user types something. Even though we've debounced fetchHits()
, we're not using the same debounced function on every render of the component. A new debounced version of fetchHits()
is being created every time useEffect
runs. In order for this solution to work, we need to guarantee that it's always the same debounced function that is called for the lifetime of the component.
2. Keep the identity of the debounced function
To keep the identity of the function through the lifetime of the component, we'll be using the useRef
hook.
At first, it might seem like a good idea to use the useCallback
or useMemo
hooks. However, those hooks do not guarantee the identity of the value given to them across all renders, which might lead to hard to track bugs in our case. If you're not sure why this might lead to bugs, you'll find this article interesting.
function App() {
const [{ hits, hasError, isLoading }, dispatch] = useReducer(fetchReducer, {
hits: [],
isLoading: true,
hasError: false,
});
const [query, setQuery] = useState('react');
const debouncedFetchHits = useRef(
_.debounce((query) => fetchHits(query, dispatch), 500)
).current;
useEffect(() => {
debouncedFetchHits(query);
}, [debouncedFetchHits, query]);
return <>...</>;
}
You may have noticed that we added query
as an argument to the debounced version of fetchHits()
. This is necessary since we'll be using the same function throughout the lifetime of the component. If we had captured query
through a closure, the value of query
used by the debounced version of fetchHits()
would always be the one present on the component's first render. This is not a problem with dispatch
since React guarantees that the identity of dispatch
is stable through the lifetime of the component.
If you try this code now, it will look like everything is working fine. But actually, there still are some bugs that we need to fix.
3. Cancel irrelevant requests
When making asynchronous requests, we must not forget that we have no guarantees regarding how much time those requests will take to complete, neither if the requests will be completed in the same order that they were done. What this means for our app, is that a user might have an old search of his, override the result of a new one.
To further illustrate the problem, let's examine the following scenario:
- A user makes a search for MobX.
- Waits debounce time.
- Before getting a response from the API, searches for Redux.
- Waits debounce time.
Now, which search result will the user see? The answer is, we don't know. It's a race condition! Whichever API request is solved last, is the one that the user will end up seeing. And if that ends up being the search request for MobX, the user won't get what he's expecting.
One way to fix this issue is to cancel the API requests made for searches that the user is no longer interested in. To do that, we'll use Axios cancellation API, and we'll add a clean-up function to useEffect
to trigger the cancellation.
async function fetchHits(query, dispatch, cancelToken) {
dispatch({ type: 'FETCH_START' });
try {
const result = await axios(
`https://hn.algolia.com/api/v1/search?query=${query}`,
{
cancelToken,
}
);
dispatch({ type: 'FETCH_SUCCESS', payload: result.data.hits });
} catch (err) {
console.error(err);
axios.isCancel(err) || dispatch({ type: 'FETCH_FAILURE' });
}
}
function App() {
const [{ hits, hasError, isLoading }, dispatch] = useReducer(fetchReducer, {
hits: [],
isLoading: true,
hasError: false,
});
const [query, setQuery] = useState('react');
const debouncedFetchHits = useRef(
_.debounce(
(query, cancelToken) => fetchHits(query, dispatch, cancelToken),
500
)
).current;
useEffect(() => {
const { cancel, token } = axios.CancelToken.source();
debouncedFetchHits(query, token);
return () => cancel('No longer latest query');
}, [debouncedFetchHits, query]);
return <>...</>;
}
4. Avoid invoking debounced function on unmounted component
We're almost done. There's still only one minor detail we need to address. On the unlikely event that the component unmounts before the debounced fetchHits()
is invoked, dispatch
will be called on an unmounted component. Thus a warning will be shown on the console saying that our app might have memory leaks. For this specific case, there is no memory leak. But we can get rid of that warning by canceling the execution of the debounced function on the useEffect
cleanup.
useEffect(() => {
const { cancel, token } = axios.CancelToken.source();
debouncedFetchHits(query, token);
return () => cancel('No longer latest query') || debouncedFetchHits.cancel();
}, [debouncedFetchHits, query]);
From the perspective of the behavior of the app, we are done! There are, however, some simplifications that we can do.
5. Simplify
If you were looking carefully, you might have noticed that since we're canceling the debounced fetchHits()
on every useEffect
clean up, we no longer need to guarantee that the identity of the debounced fetchHits()
remains the same through the lifetime of the component. Because we'll always be canceling the old debounce function before calling the new one. Therefore, we can now debounce fetchHits()
inside the useEffect
hooks.
useEffect(() => {
const { cancel, token } = axios.CancelToken.source();
const debouncedFetchHits = _.debounce(
() => fetchHits(query, dispatch, token),
500
);
debouncedFetchHits();
return () => cancel('No longer latest query') || debouncedFetchHits.cancel();
}, [query]);
But now, we are using debounce()
as a simple timeout function. So we can use the browser's setTimeout()
instead and get rid of the Lodash dependency.
function App() {
const [{ hits, hasError, isLoading }, dispatch] = useReducer(fetchReducer, {
hits: [],
isLoading: true,
hasError: false
});
const [query, setQuery] = useState("react");
useEffect(() => {
const { cancel, token } = axios.CancelToken.source();
const timeOutId = setTimeout(() => fetchHits(query, dispatch, token), 500);
return () => cancel("No longer latest query") || clearTimeout(timeOutId);
}, [query]);
return ...
}
And we are finally done!
I could have just jumped straight to this final solution using setTimeout()
instead of having gone through Lodash debounce()
. But I think that going through all these steps is an enriching experience. Since it touches on interesting details of React and on how to correctly use a debounce function with React hooks that may be applicable in other contexts.
Put it into practice
Next time you're faced with having to perform a search only when the user stops typing, remember to:
- Use
setTimeout()
to create a small delay between when the user stops typing and making a call to the API - Clear the timeout on the
useEffect
hook cleanup to avoid making multiple calls to the API and to avoid performing actions on an unmounted component. - Cancel the requests that are no longer relevant to the user, on the
useEffect
hook cleanup, to guarantee the user won't see results of old searches instead of the most recent search.
If you enjoyed this article, you might also like this one about how to implement infinite scroll with React using Hooks and Intersection Observers.