5 steps to perform a search when user stops typing using React + Hooks in a controlled component
Tags: webdev, react, javascript
Published: 2020-07-18 16:03:23

<q>I need to perform a search when the user stops typing. But with React I can't find how it works!</q>

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:

```jsx
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:

```jsx
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.

```jsx
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:

1. A user makes a search for MobX.
2. Waits debounce time.
3. Before getting a response from the API, searches for Redux.
4. 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.

```jsx
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.

```jsx
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.

```jsx
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.

```jsx
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:

1. Use `setTimeout()` to create a small delay between when the user stops typing and making a call to the API
2. 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.
3. 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 can [follow me on twitter](https://twitter.com/JooForja) where I share my thoughts about software development and life in general.