0

I'm building an autocomplete function that uses the user's input to filter a set of meals as they type:

export class MealAutocompleteComponent {

  mealCtrl = new FormControl()
  filteredMeals: Observable<Array<Meal>>
  liveMeals$: Observable<Array<Meal>>
  mealsSnapshot: Meal[]

  constructor(private mealsQuery: MealsQuery) {
    this.liveMeals$ = this.mealsQuery.all$        // <= observable (not used)
    this.mealsSnapshot= this.mealsQuery.getAll()  // <= array (used)

    // Observe the user input and trigger filtering
    this.filteredMeals = this.mealCtrl.valueChanges.pipe(
      startWith(null),
      map((filterTerm: string | null) => {
        let mealArr: Array<Meal> = mealsQuery.getAll() // <= I'd like to use observable instead
        return filterTerm ? this._filter(filterTerm, mealArr) : mealArr
      })
    )
  }

  private _filter(value: string, meals:Array<Meal>): Array<Meal> {
    // Method for filtering meals
    const filterValue = value.toLowerCase()

    return meals.filter(meal =>
      meal.label.toLowerCase().indexOf(filterValue) === 0
    )
  }

}

How can I switch from using meals:Array to meals:Observable?

The code above all works as intended. However there are two places that we can get the overall list of meals from - an array this.mealsSnapshot, and an observable: this.liveMeals.

I've written the code above using the mealsSnapshot array. However I would prefer to use the liveMeals$ observable since, for various reasons, it offers more flexibility (and is drawn from a state store). But I've absolutely no idea what the correct RxJS operators are to do this. The liveMeals$ observable is an observable that returns a single array of meals.

I've tried something like this but a) it's got subscribers inside subscribes which I know is not the RxJS way and b) it doesn't work c) it looks a mess

    this.filteredMeals = this.mealCtrl.valueChanges.pipe(
      startWith(null),
      concatMap((filterTerm: string | null) => {
        this.liveMeals$.pipe(
          first()  
        ).subscribe((mealsArr:Meal) => {
          return filterTerm ? this._filter(filterTerm, mealsArr) : mealsArr
        }
        )
      })
    )

What would be the correct way to use liveMeals$:Observable<Array> rather than the mealsSnapshot?

Peter Nixey
  • 16,187
  • 14
  • 79
  • 133
  • I *think* you're just asking how to apply an array filter to an observable of arrays, in which case `obsOfArr.pipe(map(arr => arrFilter(arr)))` gives an observable of filtered arrays. – jonrsharpe Sep 04 '20 at 11:01
  • When writing an arrow function with a block body, one must use the `return` keyword to return. – Aluan Haddad Sep 04 '20 at 12:44
  • @AluanHaddad I believe you only need to return if you're using curly brackets `{ }` - if not then the line will be the return value. And *I believe* you only need the curly brackets if you want to execute more than one line of code. @jonrsharpe - that's part of the answer although wthout either `take` or `concatMap` it doesn't get quite far enough – Peter Nixey Sep 04 '20 at 13:39
  • Yeah, but your `concatMap` callback is braced/bracketed and lacks a `return`. Since `concatMap` callbacks are projections, I assumed you forgot it. – Aluan Haddad Sep 04 '20 at 13:45
  • 1
    ahh - my apologies - my code was screwed but it was so far screwed that I couldn't narrow down all the things that were wrong. So thank you for pointing that out - that would explain one of them – Peter Nixey Sep 04 '20 at 13:53

1 Answers1

0

You are correct in choosing a higher order operator (concatMap) to switch from one observable to another. But the subscription inside isn't required. You could switch to another observable and use map to use your condition.

If it's used in a typeahead, you could also use debounceTime operator to control the number of emissions that triggers the filter.

Try the following

this.filteredMeals = this.mealCtrl.valueChanges.pipe(
  startWith(null),
  debounceTime(300),                // <-- suspend emission if last emission was < 300ms
  concatMap((filterTerm: string | null) =>
    this.mealsQuery.all$.pipe(      // <-- or `this.liveMeals$` since they're the same
      take(1),                      // <-- take only the first value
      map(meal => filterTerm ? this._filter(filterTerm, meal) : meal)
    );
  )
);

Differences b/n first() and take(1): https://stackoverflow.com/a/42346203/6513921

ruth
  • 29,535
  • 4
  • 30
  • 57
  • That's super thank you Michael. Your code worked perfectly. I have a question though - why do you need to use either `take` or `first`. Empirically you're right - if I don't use them then the code inside the second `map` statement doesn't execute during the `valueChanges` event. But why is that - why does the `first/take` force the `all$` to emit a value? – Peter Nixey Sep 04 '20 at 13:41
  • 1
    @PeterNixey: It doesn't force the `all$` to emit a value. It makes the inner observable `this.mealsQuery.all$` to complete on it's first emission. Since you're using `concatMap`, the subsequent emissions from `valueChanges` won't be processed unless the inner subscription completes. So if the `take(1)` or `first()` isn't used, the inner observable might never complete and newer emissions from outer observable will never be used. – ruth Sep 04 '20 at 14:10
  • @PeterNixey: There are also other ways to handle it. Instead of `concatMap()` you could use the other higher order mapping operators `switchMap`, `mergeMap` or `exhaustMap`. They make sure the outer emissions are handled even if the inner observable hasn't completed yet. Each handle the outer emission differently. You could see my other answer [here](https://stackoverflow.com/a/63685990/6513921) for a very brief differences b/n them. – ruth Sep 04 '20 at 14:12
  • @PeterNixey: One more point to note: The code inside `map` would run during the `valueChanges` event regardless of `take(1)`. – ruth Sep 04 '20 at 14:25
  • all very helpful and a good prompt to try the other mappings. BTW to your last point, when it's a concatMap, removing the `take(1)` means that none of the code executes during the valueChanges event. Presumably because the concatenation of events == no event since the inner observable isn't completing. However if I use mergeMap then the code inside the inner observable does complete. Just FYI. I have to say I'm a little surprised that the inner observable completes with mergeMap (and no `take`), I wouldn't have expected that... – Peter Nixey Sep 04 '20 at 16:10
  • i.e. ```` mergeMap((filterTerm: string | null) => console.log('inside outer observer') this.mealsQuery.all$.pipe( console.log('inside inner observer') map(meal => filterTerm ? this._filter(filterTerm, meal) : meal) ); ) ```` - on valueChange, mergeMap prints both of the statements. concatMap prints neither of the statements – Peter Nixey Sep 04 '20 at 16:14
  • Sorry Michael - one last question. Why does this example all work even though the valueChanges observer is never subscribed to? I would have expected this to require `.subscribe()` somewhere but it works fine without it. Why is that? Thank you – Peter Nixey Sep 07 '20 at 16:32
  • Hi @PeterNixey, I'd depend on how the `this.filteredMeals` variable is used. Surely there is a subscription somewhere in the code. Without it, the there isn't a proper way to consume the notifications from the observable. – ruth Sep 07 '20 at 19:12
  • great spot - it's in the template with an `async` pipe. I kept looking at the component file and couldn't find it - but it was because it was in the template! – Peter Nixey Sep 07 '20 at 19:56