Reactive dumb components

Reactive dumb components

Typically, when we embed RxJS logic into the presentation tier, the outcome is that reactive flows end up concentrated within our smart components, also known as container components. The rationale is straightforward:

Being the orchestrators of the application, these smart components handle reactive asynchronous streams—like HTTP calls and state management—alongside events that originate from dumb components.

Still, dumb components can likewise gain from reactive flows, particularly when a dumb component possesses multiple @Input() fields that depend on varied asynchronous datasets. The standard approach to manage such changes is through imperative if else code within the ngOnChanges lifecycle method.

Consider this scenario: A CompanyDetail component is tasked with rendering a company alongside all its sites. It includes previous and next buttons for site navigation, shows the total site count, and displays each site’s name and address. The sites @Input() property arrives asynchronously due to an XHR request, whereas the currentSiteId @Input() property is similarly asynchronous, stemming from router parameters—an observable under the hood that fluctuates as the user moves between different sites of a company.

For this component to function correctly, both sites and currentSiteId must hold valid values at the opportune moments, and we can already foresee issues like racing conditions. Additionally, the component must determine when the previous and next buttons should be disabled. Upon clicking these buttons, it must compute the new siteId—either the preceding or succeeding one—and emit that value upward to its smart component.

You can observe the application within this Stackblitz example

This represents the smart component that incorporates the app-company-detail as a dumb component.

@Component({
  selector: 'app-company',
  template: `
    <app-company-detail
      [currentSiteId]="currentSiteId$ | async"
      [sites]="sites$ | async"
      (siteChanged)="siteChanged($event)"
    ></app-company-detail>
  `,
  styleUrls: ['./company.component.css']
})
export class CompanyComponent {
  // fetch the sites
  sites$ = this.sitesService.getSites();
  // get the asynchronous siteId from the router params
  currentSiteId$ = this.activatedRoute.params.pipe(map(p => p.siteId));

  // the dumb app-company-detail component is responsible
  // to calculate the siteId that we need to go to
  siteChanged(id: string): void {
    this.router.navigate([id]);
  }

  constructor(
    private sitesService: SitesService,
    private router: Router,
    private activatedRoute: ActivatedRoute
  ) {}
}

Here is how the imperative version of the dumb component is implemented:

@Component({
  selector: 'app-company-detail',
  template: `
    <button [disabled]="previousDisabled" (click)="previousClicked()">
      Previous site
    </button>
    <button [disabled]="nextDisabled" (click)="nextClicked()">
      Next site
    </button>
     / 
    <h2></h2>
    <p>Address: </p>
  `,
  styleUrls: ['./company-detail.component.css']
})
export class CompanyDetailComponent implements OnChanges{
  @Input() currentSiteId: string;
  @Input() sites: any[];
  @Output() siteChanged = new EventEmitter<string>();

  // we need to keep track of 5 different local properties
  // and calculate and set their values at the right time
  currentIndex = 0;
  previousDisabled: boolean;
  nextDisabled: boolean;
  currentSite: any;
  currentSiteNumber: number;

  ngOnChanges(): void {
    // this can become complex really fast
    if(this.currentSiteId && this.sites?.length > 0){
      this.currentIndex = this.sites?.map(site => site?.id).indexOf(this.currentSiteId);    
      this.currentSite = this.sites[this.currentIndex];
      this.currentSiteNumber = this.currentIndex + 1;
      this.previousDisabled = this.currentIndex === 0;
      this.nextDisabled = this.currentIndex === this.sites?.length -1
    }
  }

  previousClicked(): void {
      this.siteChanged.emit(this.sites[this.currentIndex -1].id);    
  }

  nextClicked(): void {
      this.siteChanged.emit(this.sites[this.currentIndex +1].id);    
  }
}

In the previous example, all the computation takes place within the ngOnChanges hook. While that approach works perfectly, a more RxJS-driven alternative exists. For this simple case, it might feel like overkill, but it can give you a solid foundation for handling more involved scenarios when dealing with @Input() changes.

Consider a sophisticated calendar component where dozens of @Input() properties trigger numerous calculations—some of which are synchronous, and others that are async and depend on other async values. Or think about @Input() properties that are of no use until another @Input() arrives. What if you need to merge the @Input() values with other observables inside the dumb component? In the next snippet, every line of logic in this component is treated as a stream. The initial step is to transform the @Input() properties into observables. I often apply this pattern when several @Input() properties depend on each other to produce a derived value.

export class CompanyDetailComponent{
  // input state subjects
  private currentSiteId$$ = new ReplaySubject<string>(1);
  private sites$$ = new ReplaySubject<any[]>(1);

  // input stream setters
  @Input() set currentSiteId(v: string){
    if(v){ // we don't care about null values in this case
      this.currentSiteId$$.next(v);
    }
  };
  @Input() set sites(v: any[]){
    if(v){ // we don't care about null values in this case
      this.sites$$.next(v);
    }
  };
}

Note: The $$ suffix is used here to mark that the observable is a Subject.

At this stage, we've derived observables from those @Input() properties. To simplify this process further, I've authored the ngx-reactivetoolkit library. The following snippet demonstrates how it streamlines the code:

export class CompanyDetailComponent{
  @Input() currentSiteId: string
  @Input()  sites: any[]
  @Changes('currentSiteId') currentSiteId$;
  @Changes('sites') sites$;
}

In this article, we’ll stick with the native method, though you’re encouraged to explore the toolkit whenever you get the chance—it may offer other useful utilities.

Now, back to the point. Earlier, we noted that virtually anything can be treated as a stream. That principle extends to template events as well, which can be wired directly to streams.

<button [disabled]="previousDisabled$|async" (click)="previousClicked()">
  Previous site
</button>
<button [disabled]="nextDisabled$|async" (click)="nextClicked()">
  Next site
</button>
  // this will be used to communicate with the siteChanged @Output()
  private nav$$ = new Subject<number>();

  previousClicked(): void {
    this.nav$$.next(-1); // decrement
  }
  
  nextClicked(): void {
    this.nav$$.next(+1); // increment
  }

Now we need to identify the presentation streams and the Output streams. To locate the presentation streams, just inspect the template:

<button [disabled]="previousDisabled$|async" (click)="previousClicked()">
  Previous site
</button>
<button [disabled]="nextDisabled$|async" (click)="nextClicked()">
  Next site
</button>
/ 
<ng-container *ngIf="currentSite$|async as currentSite">
  ...
</ng-container>

Scanning this template reveals the presentation streams at a glance — any stream hooked into an async pipe falls into that category.

  • previousDisabled$
  • nextDisabled$
  • currentSiteNumber$
  • totalSites$
  • currentSite$

When it comes to @Output() streams, this template has exactly one: the siteChanged @Output(). Here’s a lesser-known fact: underneath the hood, an EventEmitter is actually an observable. Once you know that, it becomes clear that any observable can stand in for the EventEmitter.

Consequently, the code snippet below:

@Output() siteChanged = new EventEmitter();

can be refactored to:

@Output() siteChanged = this.siteChanged$;

The choice here may still come down to taste. But as the data streams grow more intricate, we're confident this pattern starts to prove its worth. Picture a search field built something like this…

@Output() search$ = this.searchControl.valueChanges$.pipe(
    debounceTime(100),
    distinctUntilChanged()
)

This approach can scale dramatically!

Our next step is to compute the intermediate private streams. For an introduction to that concept, refer to the post on the SIP principle. These intermediate streams drive the presentation streams, which are what we bind to in the template and via the @Output() streams.

The currentIndex$ and indexWithSites$ streams feed into those presentation streams. The siteChanged @Output() property will consume the siteChanged$ stream. The computation is demonstrated below:

// intermediate streams
// the current index, calculated by the current site id and the sites
private currentIndex$ = combineLatest([this.currentSiteId$$, this.sites$$])
  .pipe(
    map(([currentSiteId, sites]) => 
      sites?.map(site => site?.id).indexOf(currentSiteId)    
    )
  );

// an array that always contains the currentIndex and all the sites
private indexWithSites$ = combineLatest([this.currentIndex$, this.sites$$]);

// every time the nav button is clicked, we need to calculate the id that
// needs to be emitted to the siteChanged @Output()
private siteChanged$ = this.nav$$.pipe(
  withLatestFrom(this.sites$$, this.currentIndex$),
  map(([navigationIndex, sites, currentIndex]) => 
    sites[currentIndex + navigationIndex]?.id
  )
)

With this setup, we can begin building the presentation streams and the @Output() stream. The full implementation is shown in the following code block, and a live demo is available in the StackBlitz example.


@Component({
  selector: 'app-company-detail',
  template: `
    <button [disabled]="previousDisabled$|async" (click)="previousClicked()">
      Previous site
    </button>
    <button [disabled]="nextDisabled$|async" (click)="nextClicked()">
      Next site
    </button>
    / 
    <ng-container *ngIf="currentSite$|async as currentSite">
      <h2></h2>
      <p>Address: </p>
    </ng-container>

  `,
  styleUrls: ['./company-detail.component.css']
})
export class CompanyDetailComponent{
  // local state subjects and input state subjects
  private nav$$ = new Subject<number>();
  private currentSiteId$$ = new ReplaySubject<string>(1);
  private sites$$ = new ReplaySubject<any[]>(1);

  // input stream setters
  @Input() set currentSiteId(v: string){
    if(v){
      this.currentSiteId$$.next(v);
    }
  };
  @Input() set sites(v: any[]){
    if(v){
      this.sites$$.next(v);
    }
  };

  // intermediate streams
  private currentIndex$ = combineLatest([this.currentSiteId$$, this.sites$$])
    .pipe(
      map(([currentSiteId, sites]) => 
        sites?.map(site => site?.id).indexOf(currentSiteId)    
      )
    );
  private indexWithSites$ = combineLatest([this.currentIndex$, this.sites$$]);

  private siteChanged$ = this.nav$$.pipe(
    withLatestFrom(this.sites$$, this.currentIndex$),
    map(([navigationIndex, sites, currentIndex]) => 
     sites[currentIndex + navigationIndex]?.id
    )
  )

  // output streams and presentational streams
  @Output() siteChanged = this.siteChanged$;
  previousDisabled$ = this.currentIndex$.pipe(
    map(currentIndex =>currentIndex === 0)
  )
  nextDisabled$ = this.indexWithSites$.pipe(
    map(([currentIndex, sites]) => currentIndex === sites?.length -1)
  )
  currentSite$ = this.indexWithSites$.pipe(
    map(([currentIndex, sites]) => sites[currentIndex])
  )
  totalSites$ = this.sites$$.pipe(
    map(sites => sites?.length)
  )
  currentSiteNumber$ = this.currentIndex$.pipe(
    map(v => v + 1)
  )

  previousClicked(): void {
    this.nav$$.next(-1);    
  }
  
  nextClicked(): void {
    this.nav$$.next(+1);    
  }
}

Summary

If you're aiming for full reactivity in your components, you can achieve this through the following approaches:

  • Connecting additional observables directly to @Output() properties
  • Leveraging setter functions to feed data into @Input() properties
  • Employing ngx-reactivetoolkit for a more streamlined solution

While this pattern might feel excessive for basic components, it proves valuable for complex dumb components that depend on multiple asynchronous data streams.

Angular forms course