rxjs

Re-using pipeable operators in RxJS


I have two Subjects in an Angular component that leverage the same set of pipeable operators to provide typeahead search lookups for two different form fields. Example:

this.codeSearchResults$ = this.codeInput$
                               .pipe(
                                 untilDestroyed(this),
                                 distinctUntilChanged(),
                                 debounceTime(250),
                                 filter(value => value !== null),
                                 switchMap((value: string) => {
                                   const params: IUMLSConceptSearchParams = {
                                     ...TERMINOLOGY_SEARCH_PARAMS,
                                     sabs: this.sabs,
                                     term: value
                                   };

                                   return this.terminologyService.umlsConceptSearch(params);
                                 }),
                               );

The definition for pipe appears that it will accept an arbitrary number of functions, however providing the functions via spread

this.codeSearchResults$ = this.codeInput$.pipe(...operators);

isn't working as expected. How can I provide a single input source of functions for both Subjects to keep my code DRY?

Edit

Following option #2 from Dan Kreiger's answer, my final code is as follows:

const operations = (context) => pipe(
      untilDestroyed(context),
      distinctUntilChanged(),
      debounceTime(250),
      filter(value => value !== null),
      switchMap(value => {
        const term: string = value as unknown as string;
        const params: IUMLSConceptSearchParams = {
          ...TERMINOLOGY_SEARCH_PARAMS,
          sabs: context.sabs,
          term,
        };

        return context.terminologyService.umlsConceptSearch(params) as IUMLSResult[];
      }),
    );

    this.codeSearchResults$ = this.codeInput$
                                  .pipe(
                                    tap(() => this.codeLookupLoading = true),
                                    operations(this),
                                    tap(() => this.codeLookupLoading = false),
                                  ) as Observable<IUMLSResult[]>;

    this.displaySearchResults$ = this.displayInput$
                                      .pipe(
                                        tap(() => this.displayLookupLoading = true),
                                        operations(this),
                                        tap(() => this.displayLookupLoading = false),
                                      ) as Observable<IUMLSResult[]>;

I needed to compose a couple of tap() functions that were unique per Subject and it works as expected.


Solution

  • Here are 4 possible ways.

    1. Utility function that returns a list of operators

    If you want to reuse it between contexts, you can try making a function that accepts the thisArg and returns an array of operators.

    Then you can spread the invoked function in the argument passed to pipe.

    /**
     * @param {object} thisArg - context using the typeahead
     * @returns {OperatorFunction[]}
     * 
     * list of pipepable operators 
     * that can have a dynamic `this` context
     */
    const typeAhead = thisArg => [
      untilDestroyed(thisArg),
      distinctUntilChanged(),
      debounceTime(250),
      filter(value => value !== null),
      switchMap((value: string) => {
        const params: IUMLSConceptSearchParams = {
          ...TERMINOLOGY_SEARCH_PARAMS,
          sabs: thisArg.sabs,
          term: value
        };
    
        return thisArg.terminologyService.umlsConceptSearch(params);
      })
    ]
    
    
    // subject A
    this.codeSearchResults$ = this.codeInput$
      .pipe(...typeAhead(this));
    
    // subject B
    this.articleSearchResults$ = this.articleInput$
      .pipe(...typeAhead(this));
    

    Note: terminologyService and sabs would need to be present on the this context you pass to this function.

    It looks like you are using these for components, so as long as terminologyService is being injected as a dependency and sabs is a static member of the component, it should work.


    2. Utility function that returns composed operators

    Alternatively, you could accomplish the same this by importing pipe from rxjs to chain the operators together.

    import { pipe } from "rxjs";
    
    // ... 
    
    const typeAhead = (thisArg) =>
      pipe(
        untilDestroyed(thisArg),
        distinctUntilChanged(),
        debounceTime(250),
        filter((value) => value !== null),
        switchMap((value: string) => {
          const params: IUMLSConceptSearchParams = {
            ...TERMINOLOGY_SEARCH_PARAMS,
            sabs: thisArg.sabs,
            term: value
          };
    
          return thisArg.terminologyService.umlsConceptSearch(params);
        })
      );
    

    In this case, you do not need to use the spread operator since the operators are composed together using pipe's left-to-right function composition.

    // subject A
    this.codeSearchResults$ = this.codeInput$
      .pipe(typeAhead(this));
    
    // subject B
    this.articleSearchResults$ = this.articleInput$
      .pipe(typeAhead(this));
    

    Note: terminologyService and sabs would need to be present on the this context you pass to this function.


    3. Service for custom operators

    You could make a reusable service for your custom piped operators. This would allow you to grab the terminologyService singleton from the reusable service itself.

    However, it looks like sabs will still need to be available from whereever it is coming from.

    If you choose to do this, make sure to declare TerminologyService in the top level providers - see example here

    Then you could inject it into your components.

    import { TerminologyService } from "./terminologyService.service";
    import { Injectable } from "@angular/core";
    import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
    
    
    @UntilDestroy()
    @Injectable({
      providedIn: 'root',
    })
    export class PipedOperatorsService {
      constructor(private terminologyService: TerminologyService) {}
    
      typeAhead(thisComponent) {
        return pipe(
          untilDestroyed(thisComponent),
          distinctUntilChanged(),
          debounceTime(250),
          filter((value) => value !== null),
          switchMap((value: string) => {
            const params: IUMLSConceptSearchParams = {
              ...TERMINOLOGY_SEARCH_PARAMS,
              sabs: thisComponent.sabs,
              term: value
            };
    
            return this.terminologyService.umlsConceptSearch(params);
          })
        );
      }
    }
    

    Then you can use it in a component:

    import { Component, OnInit } from "@angular/core";
    import { PipedOperatorsService } from "./pipedOperators.service";
    import { Observable } from 'rxjs';
    
    @Component({
      selector: "some-root",
      templateUrl: "./some.component.html",
    })
    export class SomeComponent implements OnInit {
      sabs = ['What', 'is', 'a', 'sab', '?', '🐶']; 
      codeSearchResults$: Observable<string[]>;
    
      constructor(private pipedOperatorsService: PipedOperatorsService){}
    
      ngOnInit() {
        this.codeSearchResults$ = this.codeInput$.pipe(
          this.pipedOperatorsService.typeAhead(this)
        );
      }
    }
    

    Note: sabs would need to be present on the this context you pass to this function. I made a dummy one here.


    4. Service for custom operators (no this context)

    If you want to make this truly reusable and not have to worry about this, you can just compose the operators you need that do not require a this context.

    import { Injectable } from "@angular/core";
    import { pipe } from "rxjs";
    import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators'
    
    
    @Injectable({
      providedIn: 'root',
    })
    export class PipedOperatorsService {    
      get typeAhead() {
        return pipe(
          distinctUntilChanged(),
          debounceTime(250),
          filter((value) => value !== null),
        );
      }
    }
    

    Then be sure to add the specific operators you need in the component:

    import { Component, OnInit } from "@angular/core";
    import { TerminologyService } from "./terminologyService.service";
    import { PipedOperatorsService } from "./pipedOperators.service";
    import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
    import { switchMap } from 'rxjs/operators';
    import { Observable } from 'rxjs';
    
    
    @UntilDestroy()
    @Component({
      selector: "some-root",
      templateUrl: "./some.component.html",
    })
    export class SomeComponent implements OnInit {
      sabs = ['What', 'is', 'a', 'sab', '?', '🐶']; 
      codeSearchResults$: Observable<string[]>;
    
      constructor(private pipedOperatorsService: PipedOperatorsService, private terminologyService: TerminologyService){}
    
      ngOnInit() {
        this.codeSearchResults$ = this.codeInput$.pipe(
          untilDestroyed(this),
          this.pipedOperatorsService.typeAhead,
          switchMap((value: string) => {
            const params: IUMLSConceptSearchParams = {
              ...TERMINOLOGY_SEARCH_PARAMS,
              sabs: this.sabs,
              term: value
            };
    
            return this.terminologyService.umlsConceptSearch(params);
          })
        );
      }
    }
    

    Note: sabs would need to be present on the this context you pass to this function. I made a dummy one here.

    It's been a while since I've used angular. I hope some of these examples are useful.