Where to write UI logic in an Angular app?

Viewed 258

Assume I have an Angular app with two views. The first view shows a preview of some object, let's say a car, and the other view shows detailed information of that car. The car model would be something similar to:

export class Car {
    model: string;
    type: CarTypeEnum;
    ...;
}

Let's say I want both views to show me an Icon representing the car's type. The logic would be:

switch(someCar.type) {
    case CarTypeEnum.HATCHBACK: return icons.hotHatch;
    case CarTypeEnum.SEDAN: return icons.sedan;
    case CarTypeEnum.SUV: return icons.suv;
    case CarTypeEnum.COUPE: return icons.coupe;
    case CarTypeEnum.VAN: return icons.van;
    case CarTypeEnum.WAGON: return icons.wagon;
}

Where should this logic for getting the icon based on the car type go?

  • Should I create a property in the car model class?
export class Car {
    model: string;
    type: CarTypeEnum;
    ...;

    get typeIcon() { 
        // switch goes here
    }
}

This feels somewhat right, given that I'm using this in two separate views, but it also feels like I'm polluting the model.

  • Should I add this code to a method and duplicate it into the two views component class? What if I had to use this code in 10 views, would I duplicate this logic 10 times in each of their respective component?

  • Should I create some static helper class containing a method with this logic, taking a car as a parameter, which I would then call in each component class?

  • Should I add the logic straight into the view?

<div *ngIf="car.type == carType.HATCHBACK" class="hatchback-icon-class"> ... </div>
<div *ngIf="car.type == carType.COUPE" class="coupe-icon-class"> ... </div>
...
  • Should I generate an Angular component that would take the car type as an input? This would be reusable but kind of overkill just to render this icon, no?
2 Answers

If you are going to use that icon all along the app, I would recommend creating a component with an input binding (as you say in your last paragraph).

Angular really encourages you to make presentational components that are easy to test and reuse, following KISS and SOLID principles.

More info in this article: https://indepth.dev/posts/1066/presentational-components-with-angular

My preferred strategy is to use services. You can either create it as a singleton so that when you load a car it's available for all components, or you can load it individually so that each component can load a different car.

Here's an example of what that looks like.

/services/car.service.ts

The service that loads a car from your data source and provides a standardized interface to all of your components

import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';

// If you want this to behave as a singleton, add {providedIn: 'root'} to @Injectable
@Injectable()
export class CarService {
    private _car = new BehaviorSubject<any>(null);
    private _carSnapshot;
    
    // Method called by your components to load a specific car
    load(carId: string): Promise<any> {
        return this.getCarInfoFromWherever(carId);
    }

    // Returns an observable of the currently loaded car
    car$(): Observable {
        return this._car.asObservable();
    }

    // Method to retrieve the car data from whatever datasource you're using
    getCarInfoFromWherever(carId: string): Promise<any> {
        return new Promise(async (resolve: any) => {
            // Retrieve the car information from wherever it is such as a database.
            const carInfo = await DbGetCarInfo(carId);

            // Set an object for easy access to current vehicle
            this._carSnapshot = carInfo;

            // Update your observable
            this._car.next(carInfo);
        });
    }

    // Example of abstraction to retrieve car attributes
    getType(): string {
      if (this._carSnapshot)
          return this._carSnapshot['type'];

      return null;
    }
}

/components/main/main.component.ts

A component that wants to display a Ford Pinto

import { Component } from '@angular/core';
import { distinctUntilChanged } from 'rxjs/operators';

@Component({
    selector: 'app-main',
    templateUrl: './main.component.html',
    styleUrls: ['./main.component.scss']
})
export class MainComponent {
    private _subscription;

    constructor(
        // Inject the CarService into our component
        public carSvc: CarService
    ) {
        // Tell CarService which car to load
        this.carSvc.load('FordPinto').then();
    }

    ngOnInit(): void {
        // Subscribe to the car service observable
        this._subscription = this.carSvc.car$()
            .pipe(distinctUntilChanged())
            .subscribe((car: any) => {
                // The car has been loaded, changed, do something with the data.
                console.log("Car Type:", this.carSvc.getType());
            });
    }

    // Unsubscribe from the CarService observable
    ngOnDestroy(): void {
        this._subscription.unsubscribe();
    }
}

/components/dashboard/dashboard.component.ts

A component that wants to display a Ferrari Testarossa

import { Component, OnInit } from '@angular/core';
import { distinctUntilChanged } from 'rxjs/operators';

@Component({
    selector: 'app-dashboard',
    // Here's an example of using the observable in a template
    template: `<div>{{carSvc.car$() | json}}`,
    styleUrls: ['./dashboard.component.scss']
})
export class DashboardComponent implements OnInit, O {

    constructor(
        public carSvc: CarService
    ) {
        this.carSvc.load('FerrariTestarossa').then();
    }

    ngOnInit(): void {
        this.carSvc.car$()
            .pipe(distinctUntilChanged())
            .subscribe((car: any) => {
                // Do something with the car information
            });
    }

    // Unsubscribe from the CarService observable
    ngOnDestroy(): void {
        this._subscription.unsubscribe();
    }
}

In this example two separate components load up to separate cars.

Related