How to add debounce to react onChange Input

Viewed 5411

I'm trying to add debounce functionality to my React app, and want to do it without a library like loadash, 3rd party node module, etc. I tried with some posts there, but nothing worked for me.

Basically, in handleSearch I just dispatch redux action which performs a query to API endpoint and in input bind value to pice of redux state and call handleSearch onChange.

My code is:

const handleSearch = (e: React.FormEvent<HTMLInputElement>) => {
        const value = e.currentTarget.value
        dispatch(setSearch(value))
    }

And later in return

<input type="text" value={searchQuery} onChange={handleSearch} />

Also, my action:

export const searchMovies = (category: String, searchQuery: string) => async (dispatch: Dispatch<ControlDispatchTypes>) => {
    try {
        dispatch({
            type: SEARCH_LIST_LOADING
        })
        let res: any;

        if (searchQuery.length >= 3) {
            res = await axios.get(`https://api.themoviedb.org/3/search/${category}?api_key=xxxxxxxxxx&query=${searchQuery}`)
        }

        dispatch({
            type: SEARCH_LIST_SUCCESS,
            payload: res.data.results
        })

    } catch (error) {
        dispatch({
            type: SEARCH_LIST_FAIL
        })
    }
}

and a piece of the reducer used for the search:

...
 case SET_SEARCH:
            return {
                ...state,
                search: action.payload
            }
        case SEARCH_LIST_LOADING:
            return {
                ...state,
                searchLoading: false
            }
        case SEARCH_LIST_SUCCESS:
            return {
                ...state,
                searchLoading: false,
                items: action.payload

            }
        case SEARCH_LIST_FAIL:
            return {
                ...state,
                searchLoading: false
            }
...
3 Answers

It isn't clear how you are calling searchMovies() but I think the easiest way of doing this kind of thing is by wrapping your dispatch in a callback that's been debounced.

const debounce = (fn, delay) => {
  let timeout = -1;

  return (...args) => {
    if (timeout !== -1) {
      clearTimeout(timeout);
    }

    timeout = setTimeout(fn, delay, ...args);
  };
};

export const App = () => {
  const query = useSelector(selectQuery);
  const dispatch = useDispatch();
  
  const requestMovies = useMemo(() => {
    return debounce((query) => {
      dispatch(searchMovies(query))
    }, 300);
  }, []);

  const onQueryChange = useCallback((q) => {
    dispatch(setSearch(q));
    requestMovies(q);
  }, []);

  return (
    <div className="App">
      <input value={query} onChange={(e) => onQueryChange(e.currentTarget.value)} />
    </div>
  );
}

That is the simple solution. If you want bonus points consider writing a custom hook to pull contain all of that state and logic. It cleans up the components and makes the movie search reusable.

const QUERY_DEBOUNCE_PERIOD = 400;

const useMovieSearch = () => {
  const dispatch = useDispatch();
  const query = useSelector(selectQuery);
  const category= useSelector(selectCategory);
  const fence = useRef("");

  const requestMovies = useMemo(() => {
    const request = async (category, query) => {
      const uri = `https://api.themoviedb.org/3/search/${category}?api_key=xxxxxxxxxx&query=${query}`;
      fence.current = uri;

      dispatch({
        type: SEARCH_LIST_LOADING
      });

      try {
        if (query.length >= 3) {
          const res = await axios.get(uri);

          if (fence.current === uri) {
            dispatch({
              type: SEARCH_LIST_SUCCESS,
              payload: res.data.results
            });
          }
        }
      } catch (error) {
        dispatch({
          type: SEARCH_LIST_FAIL
        })
      }
    };

    return debounce(request, QUERY_DEBOUNCE_PERIOD);
  }, []);

  const searchMovies = useCallback((category, query) => {
    dispatch({ type: SET_SEARCH, payload: query });
    requestMovies(category, query);
  }, [requestMovies]);

  return {
    query,
    category,
    searchMovies
  };
};

This should look like pretty standard stuff. I just moved all of the searchMovies logic into a custom hook. I also added fencing. Because of the asynchrony of the internet, you are not guaranteed to get results in the order the request were sent out. This simply ignores all responses except for the most recent request.

Usage is pretty much what you'd expect.

const SearchBar = () => {
  const [query, category, searchMovies] = useMovieSearch();

  return <input value={query} onChange={(e) => searchMovies(category, e.currentTarget.value)} />;
};

I created a custom hook that encapsulates the debouncing.

import { useMemo, useState } from 'react';

const debounce = (fn: any, delay: number) => {
  let timeout = -1;
  return (...args: any[]) => {
    if (timeout !== -1) {
      clearTimeout(timeout);
    }
    timeout = setTimeout(fn, delay, ...args);
  };
};

export function useStateDebounced<T>(initialValue: T, delay: number) {
  const [inputValue, _setInputValue] = useState<T>(initialValue);

  const [debouncedInputValue, setDebouncedInputValue] = useState<T>(
    initialValue
  );

  const memoizedDebounce = useMemo(
    () =>
      debounce((value: T) => {
        setDebouncedInputValue(value);
      }, delay),
    [delay]
  );

  const setInputValue = (value: T | ((prevState: T) => T)) => {
    if (value instanceof Function) {
      _setInputValue((p) => {
        const mutated = value(p);
        memoizedDebounce(mutated);
        return mutated;
      });
    } else {
      _setInputValue(value);
      memoizedDebounce(value);
    }
  };

  return [inputValue, debouncedInputValue, setInputValue] as const;
}

Usage in code:

export default function App() {
  const [value, debouncedValue, setValue] = useStateDebounced('', 1000);

  const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  };

  return (
    <div className="App">
      {debouncedValue}
      <input type="text" value={value} onChange={handleInputChange} />
    </div>
  );
}

View on CodeSandbox


Edit:

Just use https://www.npmjs.com/package/use-debounce

I've seen React Redux projects use this resource for adding debounce to their project. It's taken from https://davidwalsh.name/javascript-debounce-function

export const debounce = (func, wait, immediate) => {
    let timeout;
    return function () {
        const later = () => {
            timeout = null;
            if (!immediate) func.apply(this, arguments);
        };
        const callNow = immediate && !timeout;
        clearTimeout(timeout);
        timeout = setTimeout(later, wait);
        if (callNow) func.apply(this, arguments);
    };
};
Related