Search code examples
javascriptreactjsreduxreact-reduxcomponents

Execute api request when user stops typing search box


I'm building a search field that is fetching from a data base upon users input and I'm struggling a bit. At the moment, it is fetching data in every keystroke, which is not ideal. I have looked at different answers and it seems that the best option is to do this in componentDidUpdate() and get a ref of the input feel to compare this with the current value through a setTimeout().

I have tried this, but I'm still fetching during every keystroke, not sure why? See a sample of the component below:


class ItemsHolder extends Component {
    componentDidMount() {
        //ensures the page is reloaded at the top when routing
        window.scrollTo(0, 0);
        this.props.onFetchItems(this.props.search);
    }

    componentDidUpdate(prevProps, prevState) {
        if (prevProps.search !== this.props.search) {
            console.log(
                this.props.search ===
                    this.props.searchRef.current.props.value.toUpperCase()
            );
            setTimeout(() => {
                console.log(
                    this.props.search ===
                        this.props.searchRef.current.props.value.toUpperCase()
                );
                if (
                    this.props.search ===
                    this.props.searchRef.current.props.value.toUpperCase()
                ) {
                    this.props.onFetchItems(this.props.search, this.props.category);
                }
            }, 500);
        }
    }

I'm using Redux for state management. Here is the function that is called when fetching items:

export const fetchItemsFromServer = (search) => {
    return (dispatch) => {
        dispatch(fetchItemsStart());
        const query =
            search.length === 0 ? '' : `?orderBy="country"&equalTo="${search}"`;
        axios
            .get('/items.json' + query)
            .then((res) => {
                const fetchedItems = [];
                for (let item in res.data) {
                    fetchedItems.push({
                        ...res.data[item],
                        id: item,
                    });
                }
                dispatch(fetchItemsSuccess(fetchedItems));
            })
            .catch((error) => {
                dispatch(fetchItemsFail(error));
            });
    };
};

This is how I'm setting the ref in the search component:

class Search extends Component {
    constructor(props) {
        super(props);
        this.searchInput = React.createRef();
    }
    componentDidMount() {
        this.props.onSetRef(this.searchInput);
    }

    render() {
        return (
            <Input
                ref={this.searchInput}
                toolbar
                elementType={this.props.inputC.elementType}
                elementConfig={this.props.inputC.elementConfig}
                value={this.props.inputC.value}
                changed={(event) => this.props.onChangedHandler(event)}
            />
        );
    }
}

Based on a tutorial I found this should work. For your reference, see the code from this tutorial. I don't see why wouldn't the above work. The only difference is that the tutorial uses hooks.

const Search = React.memo(props => {
  const { onLoadIngredients } = props;
  const [enteredFilter, setEnteredFilter] = useState('');
  const inputRef = useRef();

  useEffect(() => {
    const timer = setTimeout(() => {
      if (enteredFilter === inputRef.current.value) {
        const query =
          enteredFilter.length === 0
            ? ''
            : `?orderBy="title"&equalTo="${enteredFilter}"`;
        fetch(
          'https://react-hooks-update.firebaseio.com/ingredients.json' + query
        )
          .then(response => response.json())
          .then(responseData => {
            const loadedIngredients = [];
            for (const key in responseData) {
              loadedIngredients.push({
                id: key,
                title: responseData[key].title,
                amount: responseData[key].amount
              });
            }
            onLoadIngredients(loadedIngredients);
          });
      }
    }, 500);
    return () => {
      clearTimeout(timer);
    };
  }, [enteredFilter, onLoadIngredients, inputRef]);

Following recommendation to debounceInput:

import React, { Component } from 'react';
// import classes from './Search.css';
import Input from '../../UI/Input/Input';
// redux
import * as actions from '../../../store/actions/index';
import { connect } from 'react-redux';

class Search extends Component {
    componentDidUpdate(prevProps, prevState) {
        if (prevProps.search !== this.props.search) {
            this.props.onFetchItems(this.props.search, this.props.category);
        }
    }

    debounceInput = (fn, delay) => {
        let timerId;
        return (...args) => {
            clearTimeout(timerId);
            timerId = setTimeout(() => fn(...args), delay);
        };
    };

    render() {
        return (
            <Input
                toolbar
                elementType={this.props.inputC.elementType}
                elementConfig={this.props.inputC.elementConfig}
                value={this.props.inputC.value}
                changed={(event) =>
                    this.debounceInput(this.props.onChangedHandler(event), 500)
                }
            />
        );
    }
}

const mapStateToProps = (state) => {
    return {
        inputC: state.filtersR.inputConfig,
        search: state.filtersR.search,
    };
};

const mapDispatchToProps = (dispatch) => {
    return {
        onChangedHandler: (event) => dispatch(actions.inputHandler(event)),
        onFetchItems: (search, category) =>
            dispatch(actions.fetchItemsFromServer(search, category)),
    };
};

export default connect(mapStateToProps, mapDispatchToProps)(Search);

Here is the final solution after help here:

import React, { Component } from 'react';
// import classes from './Search.css';
import Input from '../../UI/Input/Input';
// redux
import * as actions from '../../../store/actions/index';
import { connect } from 'react-redux';

const debounceInput = (fn, delay) => {
    let timerId;
    return (...args) => {
        clearTimeout(timerId);
        timerId = setTimeout(() => fn(...args), delay);
    };
};

class Search extends Component {
    componentDidUpdate(prevProps, _prevState) {
        if (prevProps.search !== this.props.search) {
            this.responseHandler();
        }
    }

    responseHandler = debounceInput(() => {
        this.props.onFetchItems(this.props.search, this.props.category);
    }, 1000);

    render() {
        return (
            <Input
                toolbar
                elementType={this.props.inputC.elementType}
                elementConfig={this.props.inputC.elementConfig}
                value={this.props.inputC.value}
                changed={(event) => this.props.onChangedHandler(event)}
            />
        );
    }
}

const mapStateToProps = (state) => {
    return {
        inputC: state.filtersR.inputConfig,
        search: state.filtersR.search,
    };
};

const mapDispatchToProps = (dispatch) => {
    return {
        onChangedHandler: (event) => dispatch(actions.inputHandler(event)),
        onFetchItems: (search, category) =>
            dispatch(actions.fetchItemsFromServer(search, category)),
    };
};

export default connect(mapStateToProps, mapDispatchToProps)(Search);

Solution

  • You really just need to debounce your input's onChange handler, or better, the function that is actually doing the asynchronous work.

    Very simple debouncing higher order function:

    const debounce = (fn, delay) => {
      let timerId;
      return (...args) => {
        clearTimeout(timerId);
        timerId = setTimeout(() => fn(...args), delay);
      }
    };
    

    Example Use:

    fetchData = debounce(() => fetch(.....).then(....), 500);
    
    componentDidUpdate(.......) {
      // input value different, call fetchData
    }
    
    <Input
      toolbar
      elementType={this.props.inputC.elementType}
      elementConfig={this.props.inputC.elementConfig}
      value={this.props.inputC.value}
      changed={this.props.onChangedHandler}
    />
    

    Demo Code

    Edit execute-api-request-when-user-stops-typing-search-box

    const debounce = (fn, delay) => {
      let timerId;
      return (...args) => {
        clearTimeout(timerId);
        timerId = setTimeout(fn, delay, [...args]);
      };
    };
    
    const fetch = (url, options) => {
      console.log("Fetching", url);
      return new Promise((resolve) => {
        setTimeout(() => {
          console.log("Fetch Resolved");
          resolve(`response - ${Math.floor(Math.random() * 1000)}`);
        }, 2000);
      });
    };
    
    export default class App extends Component {
      state = {
        search: "",
        response: ""
      };
    
      changeHandler = (e) => {
        const { value } = e.target;
        console.log("search", value);
        this.setState({ search: value });
      };
    
      fetchData = debounce(() => {
        const { search } = this.state;
        const query = search.length ? `?orderBy="country"&equalTo="${search}"` : "";
    
        fetch(
          "https://react-hooks-update.firebaseio.com/ingredients.json" + query
        ).then((response) => this.setState({ response }));
      }, 500);
    
      componentDidUpdate(prevProps, prevState) {
        if (prevState.search !== this.state.search) {
          if (this.state.response) {
            this.setState({ response: "" });
          }
          this.fetchData();
        }
      }
    
      render() {
        const { response, search } = this.state;
        return (
          <div className="App">
            <h1>Hello CodeSandbox</h1>
            <h2>Start editing to see some magic happen!</h2>
    
            <label>
              Search
              <input type="text" value={search} onChange={this.changeHandler} />
            </label>
    
            <div>Debounced Response: {response}</div>
          </div>
        );
      }
    }