reactjsmaterial-uiautocomplete

Debouncing Material UI Autocomplete not working properly


I'm trying to implement debounce for mui autocomplete, but it's not working well.

I want to send a debounced request to server when inputValue change.

Am I miss something?

It looks loadData fired every input change. Only first load debounce works.

https://codesandbox.io/s/debounce-not-working-j4ixgg?file=/src/App.js

Here's the code from the sandbox:

import { useState, useCallback } from "react";
import { Autocomplete, TextField } from "@mui/material";
import { debounce } from "lodash";
import topFilms from "./topFilms";

export default function App() {
  const [value, setValue] = useState(null);
  const [inputValue, setInputValue] = useState("");
  const [options, setOptions] = useState([]);

  const loadData = () => {
    // sleep(1000)
    const filteredOptions = topFilms.filter((f) =>
      f.title.includes(inputValue)
    );
    // This log statement added by Ryan Cogswell to show why it isn't working.
    console.log(
      `loadData with ${filteredOptions.length} options based on "${inputValue}"`
    );
    setOptions(filteredOptions);
  };

  const debouncedLoadData = useCallback(debounce(loadData, 1000), []);

  const handleInputChange = (e, v) => {
    setInputValue(v);
    debouncedLoadData();
  };

  const handleChange = (e, v) => {
    setValue(v);
  };

  return (
    <div className="App">
      <Autocomplete
        value={value}
        inputValue={inputValue}
        onChange={handleChange}
        onInputChange={handleInputChange}
        disablePortal
        options={options}
        getOptionLabel={(option) => option.title}
        isOptionEqualToValue={(option, value) => option.title === value.title}
        id="combo-box-demo"
        renderInput={(params) => <TextField {...params} label="Movie" />}
      />
    </div>
  );
}

Solution

  • tldr: codesandbox link

    You can accomplish this with a few hooks working together. First lets look at a hook for debouncing state:

    function useDebounce(value, delay, initialValue) {
      const [state, setState] = useState(initialValue);
    
      useEffect(() => {
        console.log("delaying", value);
        const timer = setTimeout(() => setState(value), delay);
    
        // clear timeout should the value change while already debouncing
        return () => {
          clearTimeout(timer);
        };
      }, [value, delay]);
    
      return state;
    }
    

    The above takes in a value and returns the debounced value from the hook later. The callback at the end of the useEffect prevents a bunch of timers triggering one after the other.

    Then your component can be reduced to:

    export default function App() {
      const [value, setValue] = useState(null);
      const [inputValue, setInputValue] = useState("");
      const [options, setOptions] = useState([]);
    
      const debouncedValue = useDebounce(inputValue, 1000);
    
      // fetch data from server
      useEffect(() => {
        console.log("fetching", debouncedValue);
        const filteredOptions = topFilms.filter((f) =>
          f.title.includes(debouncedValue)
        );
        setOptions(filteredOptions);
      }, [debouncedValue]);
    
      const handleInputChange = (e, v) => {
        setInputValue(v);
      };
    
      const handleChange = (e, v) => {
        setValue(v);
      };
    
      return (
        <div className="App">
          <Autocomplete
            // props
          />
        </div>
      );
    }
    

    The useEffect here is run when the dependency debouncedValue is changed via react state business. When typing in the TextField your console should look like:

    delaying s
    delaying sp
    delaying spi
    fetching spi
    delaying spir
    fetching spir
    

    This useEffect in App leaves you with a good place to do your fetch to a server like you mentioned you'll need. The useEffect could easily be replaced with something like useQuery