Declarative approach with RxJS Observables simplifies the handling of asynchronous tasks, promoting a more concise and expressive codebase. It enables the creation of observable streams that succinctly represent data flow, enhancing both understanding and collaboration among developers. This approach not only streamlines the implementation of complex logic but also facilitates the debugging and testing processes.

By using a declarative approach you can react more easily to user actions, share data between components, manage data from multiple sources, maintain a cleaner code base, and use more easily certain async features.

Procedural Approach

Very often RxJS Observables are used in Angular to handle data that comes from a REST Api endpoint. A method is created in a service that sends a request to the API endpoint and gets the response, handling any errors that might occur. This is how this flow goes:

Template > Component > Service > HTTP Request > API > HTTP Response > Service > Component > Template

Bellow we will exemplify retrieving data from a server using http requests and RxJS Observables. We are using standalone Angular components but you can do this using module based components as well.

To simulate the a webserver with REST Api endpoints we use InMemoryWebApiModule library, that intercepts Angular Http and HttpClient requests that would otherwise go to the remote server and redirects them to an in-memory data store that you control. The data is actually put in an array in data.ts file, but you can read more about this in the readme.md file of the GitHub repo attached. Now let's just focus on Procedural Approach to get data from the server using RxJS Observables.

Here is the code in the data.service.ts, which gets data from the server:

  import { Injectable } from '@angular/core';
  import { HttpClient } from '@angular/common/http';
  import { Observable, tap } from 'rxjs';
  import { Product } from './data/product';

  @Injectable({
    providedIn: 'root',
  })
  export class DataService {
    private apiUrl = 'api/products'; 

    constructor(private http: HttpClient) {}

    // Procedural approach to retrieve data from the REST API
    getData(): Observable<Product[]> {
      return this.http.get<Product[]>(this.apiUrl)
          .pipe(
            tap( (x) => console.log('Procedural Approach in service', x)),
        );
    }
  }

We inject the HttpClient in the constructor and we implemented a getData() method that returns and Observable of the type Product array (we created an interface in data/product.ts that expects and object with certain properties). getData() method returns the response of the http.get request on api.products url as an observable. Next, the component calls this service and returns the result in the template.

The code in the data.component.ts:

  import { Component, OnDestroy, OnInit } from '@angular/core';
  import { DataService } from '../data.service';
  import { Product } from './product';
  import { NgFor, NgIf } from '@angular/common';
  import { Subscription } from 'rxjs';

  @Component({
    selector: 'app-data',
    standalone: true,
    imports: [NgFor, NgIf],
    templateUrl: './data.component.html',
    styleUrl: './data.component.css'
  })
  export class DataComponent implements OnInit, OnDestroy {

    products: Product[] = [];
    errorMessage = '';
    sub!: Subscription;

	//private dataService = inject(DataService);
    constructor(private dataService: DataService) {}
    
    ngOnInit() {
      this.getDataFromApi();
    }

    getDataFromApi() {
      this.sub = this.dataService.getData().subscribe(
        {
          next: (data) => {
            // Handle the retrieved data
            this.products = data;
          },  
          error: (error) => {
            // Handle errors
            this.errorMessage = error.body.error;
          }
        }
      );
    }

    ngOnDestroy() {
     this.sub.unsubscribe();
    }
  }

In the data.component.ts file we import the DataService and inject it into the contructor. We could do this using the inject() method as well, we've put that code in a comment just to have it as a refference.

The getDataFromApi() method we implemented here subscribes to the DataService and attaches the Product[] observable to the products variable. If an error occurs, it is displayed in the template.

We implemented OnInit and OnDestroy lifecycle hooks and we call getDataFromApi() method within OnInit. To be able to unsubscribe from the observable, we attached the subscription to the sub variable.

Finally, we unsubscribe from the subscription at the end, on the OnDestroy lifecycle hook.

If you are new to RxJS Observables and you don't know how it works and why you should unsubscribe from a subscription, checkout this article: Understanding RxJS Observables: A Dive Into Reactive Programming

In the template we only use data coming from the component (Best Practice). Since within the observable we are adding every next item to the products array, in the component we will use that array to output the items from the observable.

We also have a message, so we will start by adding displaying the message with an ngIf structural directive:
<div *ngIf="errorMessage">{{ errorMessage }}</div>

Since we have an array, we will use ngFor directive to iterate through the array directly in the template:
<div *ngFor="let product of products" class="product-item">

Code in data.template.html:

  <div *ngIf="errorMessage">{{ errorMessage }}</div>
  <div *ngFor="let product of products" class="product-item">
    <h3>{{ product.name }}</h3>
    <p><strong>Product Code:</strong> {{ product.code }}</p>
    <p><strong>Description:</strong> {{ product.description }}</p>
    <p><strong>Price:</strong> ${{ product.price.toFixed(2) }}</p>
    <p><strong>Stock:</strong> {{ product.stock }}</p>
  </div>

Declarative Approach

If in the procedural approach you call a method to subscribe to the observable, so you use a procedure, in the declarative approach you have to declare it.

Let's start with the service. If now in data.service.ts we have the method getData() which is called from the component and that returns and observable from http.client.get, we will change this and declare this as a readonly variable. Copy what's in getData() method after return, write above getData() method a readonly variable called products$ and paste what you copied. It should look like this:

readonly products$ = this.http.get<Product[]>(this.apiUrl)
    .pipe(
      tap( (x) => console.log('Declarative Approach in service', x)),
    );
Bellow is the procedural approach that uses getData() method, above is the declarative approach, where we added what getData() used to return.

getData(): Observable<Product[]> {
   return this.http.get<Product[]>(this.apiUrl)
   	.pipe(
         tap( (x) => console.log('Procedural Approach http.get pipeline', x)),
    );
}
Declarative Approach - Code in data.service.ts. You can compare it with the procedural approach, which is the commented code.

  import { Injectable } from '@angular/core';
  import { HttpClient } from '@angular/common/http';
  import { Observable, tap } from 'rxjs';
  import { Product } from './data/product';

  @Injectable({
    providedIn: 'root',
  })
  export class DataService {
    private apiUrl = 'api/products'; 

    constructor(private http: HttpClient) {}

    // Declarative approach to retrieve data from the REST API:
    readonly products$ = this.http.get<Product[]>(this.apiUrl)
      .pipe(
        tap( (x) => console.log('Declarative Approach in service', x)),
      );

    // Procedural approach to retrieve data from the REST API:
    // getData(): Observable<Product[]> {
    //   return this.http.get<Product[]>(this.apiUrl)
    //   	.pipe(
      //       tap( (x) => console.log('Procedural Approach http.get pipeline', x)),
    //     );
    // }
  }

As you can see, we added the http call and everything that getData() method was returning to a new readonly variable. The "$" at the end of the variable name is a convention so that we know it's an observable. If you use that variable somwhere bellow in the code, it's better to know you are dealing with an observable.

We added readonly because we don't want to risk overriding this variable somewhere else, so we can be sure the only values coming from this variable are from the observable.

Next, we change the component code also to a declarative approach. We add everything that was attached to this.sub variable to the products$ readonly variable.

We are not going to refference this.dataService.getData().subscribe() anymore, as this method is commented in the service and is not available anymore. Instead, we are refferencing to the products$ variable in the service, this.dataService.products$.

We will pipe that obervable and use tap to show a console.log message and use catchError operator to attach the error to this.errorMessage:

 readonly products$ = this.dataService.products$
      .pipe(
        tap( (x) => console.log('Declarative Approach in Component', x)),
        catchError( (err) => {
          this.errorMessage = err;
          return EMPTY;
        })
      );
Declarative Approach - Code in data.component.ts. You can compare it with the procedural approach, which is the commented code.

  import { Component } from '@angular/core';
  import { DataService } from '../data.service';
  import { AsyncPipe, NgFor, NgIf } from '@angular/common';
  import { EMPTY, catchError, tap } from 'rxjs';

  @Component({
    selector: 'app-data',
    standalone: true,
    imports: [AsyncPipe, NgFor, NgIf],
    templateUrl: './data.component.html',
    styleUrl: './data.component.css'
  })
  export class DataComponent{

    errorMessage = '';
    // sub!: Subscription;

    constructor(private dataService: DataService) {}

    // Declarative approach to get data from the data service:
    readonly products$ = this.dataService.products$
      .pipe(
        tap( (x) => console.log('Declarative Approach in Component', x)),
        catchError( (err) => {
          this.errorMessage = err;
          return EMPTY;
        })
      );
      
    // Procedural approach to get data from the data service:
    // ngOnInit() {
    //   this.getDataFromApi();
    // }

    // getDataFromApi() {
    //   this.sub = this.dataService.getData().subscribe(
    //     {
    //       next: (data) => {
    //         // Handle the retrieved data
    //         this.products = data;
    //       },    
    //       error: (error) => {
    //         // Handle errors
    //         this.errorMessage = error.body.error;
    //       }
    //     }
    //   );
    // }

    // ngOnDestroy() {
    //  this.sub.unsubscribe();
    // }

  }
Declarative Approach - code in data.template.html

  <div *ngIf="errorMessage">{{ errorMessage }}</div>
  <div *ngFor="let product of products$ | async" class="product-item">
    <h3>{{ product.name }}</h3>
    <p><strong>Product Code:</strong> {{ product.code }}</p>
    <p><strong>Description:</strong> {{ product.description }}</p>
    <p><strong>Price:</strong> ${{ product.price.toFixed(2) }}</p>
    <p><strong>Stock:</strong> {{ product.stock }}</p>
  </div>

As you can see, the only difference is at the ngFor loop. Since now the data in the component is an observable, and not an array as it used to be in the procedural approach, we have to refference products$ as the source of our data. But only doing this is not enough, as you will get an error saying that "Type Observable is not assignable to type NgIterable":

Type Observable is not assignable to type NgIterable

To fix this error, we added async pipe to the observable, which made it iterable:
<div *ngFor="let product of products$ | async" class="product-item">

Because we are using standalone components, we have to add AsyncPipe to the imports list in the component and import it from @angular/common


  import { Component } from '@angular/core';
  import { DataService } from '../data.service';
  import { AsyncPipe, NgFor, NgIf } from '@angular/common';
  import { EMPTY, catchError, tap } from 'rxjs';

  @Component({
    selector: 'app-data',
    standalone: true,
    imports: [AsyncPipe, NgFor, NgIf],
    templateUrl: './data.component.html',
    styleUrl: './data.component.css'
  })
  export class DataComponent{ ... }

We prepared a GitHub repository with this code, you may clone it and play with it to understand more the difference between Declarative Approach and Procedural Approach. You can find this repo here >> "Declarative vs Procedural approach on RxJS Observables"

The benefits of the declarative approache are obvious: less code, clearer code, easier to maintain, no need to unsubscribe.

Get This Repository
Refferences: