Prevent execution based on previously emitted value

Viewed 369

If my typeahead gets an empty search result, any subsequent query with a norrowed down search query should be prevented. E.g. if the search for 'red' returns empty, a search for 'redcar' makes no sense.

I tried using pairwise() and scan() operator. Code snippet:

import { tap, switchMap, filter, pairwise, scan, map } from 'rxjs/operators';

this.searchForm.get('search').valueChanges
  .pipe(
    switchMap( queryString => this.backend.search(queryString))
  )
  .subscribe()

Update Given a simplified scenario: There is only the term 'apple' in the backend. The user is typing the search string (the request is not aborted by the switchMap()):

  1. 'a' -------> backend call returns 'apple'
  2. 'ap' ------> backend call returns 'apple'
  3. 'app' -----> backend call returns 'apple'
  4. 'appl' ----> backend call returns 'apple'
  5. 'apple' ---> backend call returns 'apple'
  6. 'apple p' -----> backend call returns EMPTY
  7. 'apple pi' ----> backend call returns EMPTY
  8. 'apple pie' ---> backend call returns EMPTY

The backend calls for 7. and 8. are unnecessary, because 6. already returns EMPTY. Therfore any subsequent call could be omitted. In my opinion some memoization is needed.

I would like to prevent unnecessary backend calls (http). Is there any way to achieve this in rxjs?

3 Answers

You can use the filter operator:

this.searchForm.get('search').valueChanges.pipe(
  filter(query => query)
  switchMap(query => this.backend.search(queryString))
)

You can try out this mechanism here: RxJS-Editor

Code-share did not work so you get the code here:

const { of } = Rx;
const { filter } = RxOperators;

of('foo1', 'foo2', undefined, undefined, 'foo3').pipe(
  filter(value => value)  
)

Sounds like you want to keep all failed searches and check whether current search would fail also if HTTP is called. I cant think of any elegant way of having this in one stream, but with two streams:

_failedStreams = new Subject();
failedStreams$ = _failedStreams.asObservable().pipe(
  scan((acc, curr) => [...acc, curr], []),
  startWith('')
);

this.searchForm.get('search').valueChanges
  .pipe(
    withLatestFrom(failedStreams$),
    switchMap([queryString, failedQueries] => {
      return iif(() => failedQueries.find(failed => failed.startsWith(queryString)) ?
        of('Not found') :
        callBackend(queryString);
      )
    }
  )
  .subscribe()

callBackend(queryString) {
  this.backend.search(queryString)).pipe(
    .catchError(err => if(error.status===404) {
      this._failedStreams.next(queryString);
      // do something with error stream, for ex:
      throwError(error.status)
    }
  )
}

Code is not tested, but you get the idea

This is an interesting use-case and one of a very few situations where mergeScan is useful.

Basically, you want to remember the previous search term and the previous remote call result and based on their combination you'll decide whether you should make another remote call or just return EMPTY.

import { of, EMPTY, Subject, forkJoin } from 'rxjs'; 
import { mergeScan, tap, filter, map } from 'rxjs/operators';

const source$ = new Subject();
// Returns ['apple'] only when the entire search string is contained inside the word "apple".
// 'apple'.indexOf('app') returns 0
// 'apple'.indexOf('apple ap') returns -1
const makeRemoteCall = (str: string) =>
  of('apple'.indexOf(str) === 0 ? ['apple'] : []).pipe(
    tap(results => console.log(`remote returns`, results)),
  );

source$
  .pipe(
    tap(value => console.log(`searching "${value}""`)),
    mergeScan(([acc, previousValue], value: string) => {
      // console.log(acc, previousValue, value);
      return (acc === null || acc.length > 0 || previousValue.length > value.length)
        ? forkJoin([makeRemoteCall(value), of(value)]) // Make remote call and remember the previous search term
        : EMPTY;
    }, [null, '']),
    map(acc => acc[0]), // Get only the array of responses without the previous search term
    filter(results => results.length > 0), // Ignore responses that didn't find any results
  )
  .subscribe(results => console.log('results', results));

source$.next('a');
source$.next('ap');
source$.next('app');
source$.next('appl');
source$.next('apple');
source$.next('apple ');
source$.next('apple p');
source$.next('apple pi');
source$.next('apple pie');

setTimeout(() => source$.next('app'), 3000);
setTimeout(() => source$.next('appl'), 4000);

Live demo: https://stackblitz.com/edit/rxjs-do457

Notice that after searching for "apple " there are no more remote calls. Also, after 3s when you try searching a different term "'app'" it does make a remote call again.

Related