2

I'm currently working on a search functionality in React Native using axios.

When implementing search functionality i'm using debounce from lodash to limit the amount of requests sent.

However, since request responses are not received in same order there is a possibility of displaying incorrect search results.

For example when the user input 'Home deco' in input field there will be two requests.

One request with 'Home' and next with 'Home deco' as search query text.

If request with 'Home' takes more time to return than second request we will end up displaying results for 'Home' query text not 'Home deco'

Both results should be displayed to the user sequentially, if responses are returned in order but if 'Home' request is returned after 'Home deco' request then 'Home' response should be ignored.

Following is a example code

function Search (){
    const [results, setResults] = useState([]);
    const [searchText, setSearchText] = useState('');

    useEffect(() => {
            getSearchResultsDebounce(searchText);
    }, [searchText]);

    const getSearchResultsDebounce = useCallback(
        _.debounce(searchText => {
            getSearchResults(searchText)
        }, 1000),
        []
    );

    function getSearchResults(searchText) {

        const urlWithParams = getUrlWithParams(url, searchText);
        axios.get(urlWithParams, { headers: config.headers })
             .then(response => {
              if (response.status === 200 && response.data) 
              {
                setResults(response.data);

              } else{
                  //Handle error
              }
            })
            .catch(error => {
                //Handle error
            });
    }

    return (
     <View>
        <SearchComponent onTextChange={setSearchText}/>
        <SearchResults results={results}/>
     </View>
    )

}

What is the best approach to resolve above issue?

chathup1
  • 149
  • 14
  • In a similar situation, I have checked the query text and if the query matched with the results, then I displayed the results if they matched, and otherwise, just cached. – tugrul Aug 27 '21 at 03:39
  • In a scenario where responses are returned in correct order, it will prevent us from displaying the result for 'Home' in my example? – chathup1 Aug 27 '21 at 03:54
  • Can you show your implementation ? There could be many ways of achieving this. – Avinash Thakur Aug 27 '21 at 03:55
  • promise chaining - optionally combined with request cancelling – Bravo Aug 27 '21 at 03:57
  • @AvinashThakur I have edited and added a sample implementation – chathup1 Aug 27 '21 at 04:33
  • @Bravo Can you provide a sample code? If i understand it correctly it will bring a delay to user experience overall – chathup1 Aug 27 '21 at 04:35
  • huh? no, not if you cancel previous request and make new request - since you don't want the previous request to continue when you make a new request – Bravo Aug 27 '21 at 04:41
  • @Bravo not exactly. Requests shouldn't be canceled. If second request response is returned before first response only i want to ignore it. If not results should be displayed to user for both in order. – chathup1 Aug 27 '21 at 04:51
  • well, that's simply a promise chain - but of course there's more of a delay if you chain the promises - you can't have the requests running both parallel and in serial at the same time if you want the responses of an unknown number of promises in the order they were created – Bravo Aug 27 '21 at 04:53
  • to be honest, if you want to ignore the "home" request when "home deco" request is made, why not cancel it? – Bravo Aug 27 '21 at 04:59
  • @Bravo It's not possible for me since both results should be displayed to the user if responses are returned in order but if 'Home' request is returned after 'Home deco' request then 'Home' response should be ignored. – chathup1 Aug 27 '21 at 05:05
  • Also, why have you made `getSearchResults` async - you never await – Bravo Aug 27 '21 at 05:06
  • yes, so if `Home deco` request is **started** after `Home` then you can cancel the `Home` request before you start the `Home deco` request - not sure how you can't see the logic to be honest – Bravo Aug 27 '21 at 05:07
  • some answers in https://stackoverflow.com/questions/38329209/how-to-cancel-abort-ajax-request-in-axios may be of use to you – Bravo Aug 27 '21 at 05:09
  • oh, wait ... so if user types "Home deco" and the request for `Home` finishes before `Home Deco` - then both result sets should be displayed? how? your code just does `setResults` with the current results - how would `Home` results remain when `Home Deco` results are received? – Bravo Aug 27 '21 at 05:13
  • @Bravo It simply sets the result. So it will first display result for 'Home' and then 'Home deco' giving user the sense that items are filtered while typing. Similar to Google search suggestions. – chathup1 Aug 27 '21 at 05:18
  • 1
    Unfortunately, I'm really bad at `react` ... but, in general, the way I would do it is have two variables ... `sequence` and `displayedSequence` ... when a request is made, you have an associated `mySequence = ++sequence` ... when the response is received, you check if `mySequence` is less than `displayedSequence` - if so, `setresults` and set the `displayedSequence` to `mySequence` - sorry I can't write the code for you, as I said, my `react` is very weak – Bravo Aug 27 '21 at 05:33
  • That would be a good approach :) Accepting @theepic321 answer since it's more detailed with sample code. Thank you – chathup1 Aug 27 '21 at 06:17

3 Answers3

2

If you want to avoid using external libraries to reduce package size, like axios-hooks, I think you would be best off using the CancelToken feature included in axios.

Using the CancelToken feature properly will also prevent any warnings from react about failing to cancel async tasks.

Axios has an excellent page explaining how to use the CancelToken feature here. I would recommend reading if you would like a better understanding of how it works and why it is useful.

Here is how I would implement the CancelToken feature in the example you gave:

OP clarified in the replies that they do not want to implement a cancelation feature, in that case I would go with a timestamp system like the following:

function Search () {
    //change results to be a object with 2 properties, timestamp and value, timestamp being the time the request was issued, and value the most recent results
    const [results, setResults] = useState({
        timeStamp: 0,
        value: [],
    });
    const [searchText, setSearchText] = useState('');

    //create a ref which will be used to store the cancel token
    const cancelToken = useRef();
   
    //create a setSearchTextDebounced callback to debounce the search query
    const setSearchTextDebounced = useCallback(
        _.debounce((text) => {
            setSearchText(text)
        ), [setSearchText]
    );
   
    //put the request inside of a useEffect hook with searchText as a dep
    useEffect(() => {
        //generate a timestamp at the time the request will be made
        const requestTimeStamp = new Date().valueOf();

        //create a new cancel token for this request, and store it inside the cancelToken ref
        cancelToken.current = CancelToken.source();            
        
        //make the request
        const urlWithParams = getUrlWithParams(url, searchText);
        axios.get(urlWithParams, { 
            headers: config.headers,

            //provide the cancel token in the axios request config
            cancelToken: source.token 
        }).then(response => {
            if (response.status === 200 && response.data) {
                //when updating the results compare time stamps to check if this request's data is too old
                setResults(currentState => {
                    //check if the currentState's timeStamp is newer, if so then dont update the state
                    if (currentState.timeStamp > requestTimeStamp) return currentState;
                  
                    //if it is older then update the state
                    return {
                        timeStamp: requestTimeStamp,
                        value: request.data,
                    };
                });
            } else{
               //Handle error
            }
        }).catch(error => {
            //Handle error
        });
        
        //add a cleanup function which will cancel requests when the component unmounts
        return () => { 
            if (cancelToken.current) cancelToken.current.cancel("Component Unmounted!"); 
        };
    }, [searchText]);

    return (
        <View>
            {/* Use the setSearchTextDebounced function here instead of setSearchText. */}
            <SearchComponent onTextChange={setSearchTextDebounced}/>
            <SearchResults results={results.value}/>
        </View>
    );
}

As you can see, I also changed how the search itself gets debounced. I changed it where the searchText value itself is debounced and a useEffect hook with the search request is run when the searchText value changes. This way we can cancel previous request, run the new request, and cleanup on unmount in the same hook.

I modified my response to hopefully achieve what OP would like to happen while also including proper response cancelation on component unmount.

theepic321
  • 36
  • 3
  • 1
    if you'd read the comments, OP doesn't WANT to cancel - I do understand why - because cancelling the previous request before even starting the new request will cause a "delay in the user experience" – Bravo Aug 27 '21 at 05:29
  • I modified my request to achieve what I believe OP wants to happen. I could be wrong in my approach though. – theepic321 Aug 27 '21 at 05:52
  • That's about what I was thinking (not knowing react held me back) +1 – Bravo Aug 27 '21 at 05:54
0

I think one of the most elegant solutions to this problem is using request's actual order as a state, and making each request to have its own order based on the actual order. Here's a pseudocode overview:

// ...
const [itemList, setItemList] = useState([])
const [searchOrder, setSearchOrder] = useState(0)

function getSearchResults(query) { // call on input with debounce
  // increment component level order
  setSearchOrder(searchOrder + 1)

  // set order in this scope equal to component order
  const localOrder = searchOrder

  myAxios.get('/search?query=' + query)
    .then(function (response) {
      // if the order that was assigned on this request is the same as the component level order, update the list
      if (localOrder === searchOrder) {
        setItemList(response)
      }
      // else, if the orders are not equal, it means a later call got its response faster, hence do nothing with the outdated response
    })
    // .catch(...)
}
// ...
Seangle
  • 359
  • 2
  • 10
  • what is reactive? – chathup1 Apr 12 '23 at 13:59
  • @chathup1 I believe in React it was something like `useState`. The first row is equivalent to `const [itemList, setItemList] = useState([])`, and `itemList.value = response` is just `setItemList(response)`. It's just pseudo code to give the idea, since I work in Vue, not in React and couldn't remember the syntax, but didn't find a simple answer here. I edited the answer to adapt to React more – Seangle Apr 12 '23 at 21:52
-1

We can do something like this to achieve latest api response.

function search() {
    ...
    const [timeStamp, setTimeStamp] = "";
    ...


    function getSearchResults(searchText) {

        //local variable will always have the timestamp when it was called
    const reqTimeStamp = new Date().getTime();

        //timestamp will update everytime the new function call has been made for searching. so will always have latest timestampe of last api call
    setTimeStamp(reqTimeStamp)

    axios.get(...)
        .then(response => {

            // so will compare reqTimeStamp with timeStamp(which is of latest api call) if matched then we have got latest api call response 
            if(reqTimeStamp === timeStamp) {
                return result; // or do whatever you want with data
            } else {

            // timestamp did not match
            return ;
            }

        })
        
     }

}
  • as he said : 'Both results should be displayed to the user sequentially, if responses are returned in order but if 'Home' request is returned after 'Home deco' request then 'Home' response should be ignored.' and i think this will be fulfilled right? – Nikunj Pithadiya Aug 27 '21 at 05:42
  • he is saying that 'Home' response should be ignored(so no need to dispay if we are ignoring anyway) if 'Home deco' request is returned before 'Home'. so latest timestamp will be matched – Nikunj Pithadiya Aug 27 '21 at 05:50
  • well reqtimestamp is local variable as i said.. so for each method call it should be different... while – Nikunj Pithadiya Aug 27 '21 at 05:52
  • yup it's complicated and he did say to ignore if i am not wrong – Nikunj Pithadiya Aug 27 '21 at 05:54
  • timestamp is used to store the exact time when api call is made – Nikunj Pithadiya Aug 27 '21 at 05:55
  • you mean in answer maybe.. sorry new to stackoverflow here – Nikunj Pithadiya Aug 27 '21 at 05:58
  • `timestamp` and `timeStamp` are not the same variable – Bravo Aug 27 '21 at 06:00
  • @NikunjPithadiya as i have mentioned 'Home' request should be ignored only if 'Home deco' request is returned before 'Home'. In your solution, in both scenarios if both requests are made before returning any it will only set most recent request that is 'Home deco' – chathup1 Aug 27 '21 at 06:32