DEV Community

Eduard Krivanek
Eduard Krivanek

Posted on

From Chaos to Clarity: Simplify Your Angular Code with Declarative Programming

Not that long ago I bumped into an interesting problem. I wanted to implement a “search user” dropdown. When you select a user's name, you make an API call to load more data, meanwhile the loading happens, you display a “loading…” message and once the user details are back from the server, you display those.

Kinda like the following GIF on which I will be describing the two approaches (declarative and imperative) that I used.

Image description

The Problem Description

This is a small representation of the problem which you’ve probably bumped into many times. You have a dropdown and every time you select a value, you want to load more details about the selected item from the backend.

You display a loading message until the data is not there, maybe some fancy animation, and once the data arrives you display it.

We don’t need a server for this example, it’s enough to have a mock data service as follows:

import { Injectable } from '@angular/core';
import { Observable, map, of } from 'rxjs';
import { delay } from 'rxjs/operators';

export type DataItem = {
  id: string;
  name: string;
};

export const dataItems: DataItem[] = [
  { id: 'id_1', name: 'item_1' },
  { id: 'id_2', name: 'item_2' },
  { id: 'id_3', name: 'item_3' },
  { id: 'id_4', name: 'item_4' },
  { id: 'id_5', name: 'item_5' },
];

@Injectable({
  providedIn: 'root',
})
export class DataService {
  /**
   * simulate fake API call to the server
   */
  getDataFakeAPI(itemId: string): Observable<DataItem> {
    return of(itemId).pipe(
      map(() => dataItems.find((d) => d.id === itemId)!),
      delay(1000)
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

The dataItems are items which will be displayed inside the select dropdown and every time you change the value, you will call getDataFakeAPI that returns the same value with some delay - mocking API call.

Imperative Solution

The following solution is the solution that I used initially. I will post the whole code and then go over some parts which are important in this example.

import { Component, inject, signal } from '@angular/core';
import { 
  DataItem, 
  DataService, 
  dataItems 
} from './data-service.service';

@Component({
  selector: 'app-select-imperative',
  standalone: true,
  template: `
  <!-- dropdown of users -->
  <select (change)="onChange($event)">
    @for(item of displayData; track item.id){
      <option [value]="item.id">{{ item.name }}</option>
    }
  </select>

  <h3>Selected Items </h3>

  <!-- displayed selected options -->
  @for(item of selectedItems(); track item.id){
    <div class="item-selected" (click)="onRemove(item)"> 
      {{ item.name }} 
    </div>
  }

  <!-- display loading state -->
  @if(isLoadingData()){
    <div class="item-loading"> Loading ... </div>
  }

  <!-- reset button -->
  @if(selectedItems().length > 0){
    <button type="button" class="remove" (click)="onReset()">
      Reset
    </button>
  }

  `,
})
export class SelectImperativeComponent {
  private dataService = inject(DataService);

  displayData = dataItems;

  /**
   * displayed data on the UI - loaded from the BE
   */
  selectedItems = signal<DataItem[]>([]);

  isLoadingData = signal(false);

  /**
   * on select change - load data from API
   */
  onChange(event: any) {
    const itemId = event.target.value;

    // check if already saved
    const savedIds = this.selectedItems().map((d) => d.id);
    if (savedIds.includes(itemId)) {
      return;
    }

    // set loading to true
    this.isLoadingData.set(true);

    // fake load data from BE
    this.dataService.getDataFakeAPI(itemId).subscribe((res) => {
      // save data
      this.selectedItems.update((prev) => [...prev, res]);
      // set loading to false
      this.isLoadingData.set(false);
    });
  }

  /**
   * removes item from selected array
   */
  onRemove(item: DataItem) {
    this.selectedItems.update(
       (prev) => prev.filter((d) => d.id !== item.id)
    );
  }

  onReset() {
    this.selectedItems.set([]);
  }
}
Enter fullscreen mode Exit fullscreen mode

Overall it’s not that complicated and it may be close to a solution that you yourself would write. First of all, there is nothing significantly wrong with this solution, but why exactly do I call this an imperative approach ?

In short, this is imperative, because your signals - selectedItems and isLoadingData - can be changed all over the places which leads to two major problems - debugging and multiple properties.

Right now the selectedItems is changed in 3 places and isLoadingData is changed in 2 places, however once the complexity of this feature grows, debugging may become an issue to figure out how the data flow happens in this feature. What if selectedItems and isLoadingData will be used in 10 places each, suddenly it is not that easy to understand what’s happening.

Also with the growing complexity, you may want to introduce another properties like isError = signal(false) .

Now let’s think a bit and ask the question, could we combine the selectedItems , isLoadingData and potentially a new property isError into only one property which would look something like:

{
 data: DataItem[];
 isError: boolean;
 isLoading: boolean;
}
Enter fullscreen mode Exit fullscreen mode

Declarative Solution

The result what we want to achieve with the declarative solution is that we want to have only one property (object), which will have the data and loading keys and we want to change the values for this property only in one place.

Here is the solution that I came up with:

import { Component, inject, signal } from '@angular/core';
import { 
  DataItem, 
  DataService, 
  dataItems 
} from './data-service.service';
import { Subject, map, merge, scan, startWith, switchMap } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-select-declarative',
  standalone: true,
  template: `
  <!-- dropdown of users -->
  <select (change)="onChange($event)">
    @for(item of displayData; track item.id){
      <option [value]="item.id">{{ item.name }}</option>
    }
  </select>

  <h3>Selected Items </h3>

  <!-- displayed selected options -->
  @for(item of selectedItems().data; track item.id){
    <div class="item-selected" (click)="onRemove(item)"> 
      {{ item.name }} 
    </div>
  }

  <!-- display loading state -->
  @if(selectedItems().isLoading){
    <div class="item-loading"> Loading ... </div>
  }

  <!-- reset button -->
  @if(selectedItems().data.length > 0){
    <button type="button" class="remove" (click)="onReset()">
      Reset
    </button>
  }

  `,
})
export class SelectDeclarativeComponent {
  private dataService = inject(DataService);

  displayData = dataItems;

  private removeItem$ = new Subject<DataItem>();
  private addItem$ = new Subject<string>();
  private reset$ = new Subject<void>();

  /**
   * displayed data on the UI - loaded from the BE
   */
  selectedItems = toSignal(
    merge(
      // create action to add a new item
      this.addItem$.pipe(
        switchMap((itemId) =>
          this.dataService.getDataFakeAPI(itemId).pipe(
            map((item) => ({
              item,
              action: 'add' as const,
            })),
            startWith({
              item: null,
              action: 'initLoading' as const,
            })
          )
        )
      ),
      // create action to remove an item
      this.removeItem$.pipe(
        map((item) => ({
          item,
          action: 'remove' as const,
        }))
      ),
      // create action to reset everything
      this.reset$.pipe(
        map(() => ({
          item: null,
          action: 'reset' as const,
        }))
      )
    ).pipe(
      scan(
        (acc, curr) => {
          // add reset state
          if (curr.action === 'reset') {
            return {
              isLoading: false,
              data: [],
            };
          }

          // display loading
          if (curr.action === 'initLoading') {
            return {
              data: acc.data,
              isLoading: true,
            };
          }

          // check to remove item
          if (curr.action === 'remove') {
            return {
              isLoading: false,
              data: acc.data.filter((d) => d.id !== curr.item.id),
            };
          }

          // check if already saved
          const savedIds = acc.data.map((d) => d.id);
          if (savedIds.includes(curr.item.id)) {
            return {
              isLoading: false,
              data: acc.data,
            };
          }

          // add item into the rest
          return {
            isLoading: false,
            data: [...acc.data, curr.item],
          };
        },
        { data: [] as DataItem[], isLoading: false }
      )
    ),
    {
      initialValue: {
        data: [],
        isLoading: false,
      },
    }
  );

  /**
   * on select change - load data from API
   */
  onChange(event: any) {
    const itemId = event.target.value;
    this.addItem$.next(itemId);
  }

  /**
   * removes item from selected array
   */
  onRemove(item: DataItem) {
    this.removeItem$.next(item);
  }

  onReset() {
    this.reset$.next();
  }
}

Enter fullscreen mode Exit fullscreen mode

Yes, this is longer than the previous solution, however is it more complex or simpler than the previous one?

What needs to be first highlighted that instead of changing the selectedItems on multiple places, you now have 3 subjects, each of them representing an action that can happen.

private removeItem$ = new Subject<DataItem>();
private addItem$ = new Subject<string>();
private reset$ = new Subject<void>();
Enter fullscreen mode Exit fullscreen mode

Next inside the selectedItems you use these subjects and map them into format you want to work with. For me the following format suited the most

item: DataItem;
action: 'add' | 'remove' | 'initLoading' | 'reset'
Enter fullscreen mode Exit fullscreen mode

For the addItem$ you want to use the startWith operator at the end of the pipe chain. This will allow that the first action which will be emitted when selecting a new value is initLoading and only when the API call (dataService.getDataFakeAPI) finishes, it will emit again with the action add.

You wrap each pipe mapping with the merge operator, because you want to perform some common logic despite of which one of these subjects emit.

Lastly you have the giant scan section. The scan operator is similar to reduce , however scan remembers the last computation that happened and if the scan happens again, it will use the data from the last computation - read more about scan .

Inside the scan section, you create conditions what should happen based on the action of current value that is being processed.

It may reassemble how NgRx works. You have some actions (add, remove and reset subjects) and you create reducers to updated the state of only one property.

Final Thoughts

Overall it’s up to you, the developer, which approach you choose to solve this problem. Both have some advantages and shortcomings.

If you want to play around with this example, you can find it on stackblitz or connect with me on dev.to | LinkedIn| Personal Website | Github.

Top comments (2)

Collapse
 
ranostaj profile image
Jan Ranostaj

Hey, Id' like the first approach even though you named it "chaos" to me it's more readable easy to understand and test.
The second "delcarative" has unnecessary over-engineering, which has more code prone to more errors, hard to understand at first look, not easy to read, and testing would require more effort.

Collapse
 
krivanek06 profile image
Eduard Krivanek

that's a correct observation. Even tho we "push" towards the declarative solution, it does not mean it will be more readable nor it will have any advantages over the imperative approach.

I myself am in between. I still slightly lean towards the (second) declarative solution, just because you change the values for a property only in one place (similar to ngrx), but as developers, no one cares about the implementation if the end result is achieved.